12 Φεβ, 2022
React: useState vs useReducer
Τι είναι ένας Reducer;
Γενικά μιλώντας ο reducer είναι μια συνάρτηση η οποία παίρνει δυο ορίσματα - το τρέχον state και ένα action - και επιστρέφει βάσει αυτών των ορισμάτων ένα καινούργιο state.
Ένας reducer μπορεί να εκφραστεί ως:
const reducer = (state, action) => newState;
Αν είχατε ποτέ χρησιμοποιήσει την βιβλιοθήκη Redux στο παρελθόν θα έχετε σίγουρα πολύ εμπειρία στην χρήση των reducers και actions.
Μερικά από τα πλεονεκτήματα χρήσης των reducers είναι:
- Είναι προβλέψιμοι (predictable), συμπεριφέρονται με συνέπεια και είναι χρήσιμο εργαλείο για την διαχείριση complex states.
- Γενικά είναι εύκολα να τεσταριστούν.
- Δυνατές λειτουργίες όπως undo/redo, state persistence κλπ, είναι, τις περισσότερες φορές, ευκολότερο να υλοποιηθούν με ένα reducer.
Έχοντας πει αυτά, πρέπει να τονίσω ότι δεν πρέπει με κλειστά μάτια να χρησιμοποιούμε reducers για όλες τις περιπτώσεις. Πολλές φορές - ειδικά για μικρές εφαμοργές και non-complex state - ένας reducer μπορεί να κάνει περισσότερο κακό παρά καλό καθώς ενδέχεται να προσθέσει πολύ παραπάνω πολυπλοκότητα και κώδικα στην εφαρμογή μας.
ΟΚ... άρα τι είναι το useReducer?
Η React μας παρέχει ένα hook εν ονόματι useReducer
που μπορούμε να χρησιμοποιήσουμε ώστε να διαχειριστούμε το state μας με τη βοήθεια ενός... reducer.
Η σύνταξη είναι πολύ απλή, απλώς περνάμε τον reducer και το initialState και το hook επιστρέφει το state και μια συνάρτηση dispatch έτσι:
const [state, dispatch] = useReducer(reducer, initialState);
Έπειτα μπορούμε να χρησιμοποιήσουμε τη συνάρτηση dispatch παρέχοντας action και data ώστε να ανανέωσουμε το state.
Ας δούμε ένα απλό παράδειγμα με ένα μετρητή:
const reducer = (state, action) => {
switch (action.type) {
case "INCREASE_COUNTER":
return state + 1;
case "SET_COUNTER":
return action.data;
default:
return state;
}
}
const Component = () => {
const [state, dispatch] = useReducer(reducer, 0); // initial counter state is 0
// then somewhere in the app...
return <div>
<button onClick={()=> dispatch({type: "INCREASE_COUNTER"})}>Increase counter</button>
<button onClick={()=> dispatch({type: "SET_COUNTER", data: 5})}>Set counter to 5</button>
</div>
}
Τα παραπάνω actions θα προκαλέσουν τον reducer να ανανεώσει το state.
Σημείωση: Η επιστρεφόμενη συνάρτηση dispatch
είναι memoized by default και μπορεί εύκολα να διανεμηθεί στα children components, όπως θα δούμε στο tutorial παρακάτω. Ωστόσο, οποιοδήποτε component χρησιμοποιεί το state
που επιστρέφεται από τον useReducer
θα ξαναγίνει rerender όποτε αυτό το state αλλάζει. Δεν θα επικεντρωθούμε στο φαινόμενο των re-renders σε αυτό το άρθρο. Θα κάνουμε όμως άλλα tutorials στο μέλλον τα οποία θα αναλύουν πώς μπορούμε να περιορίσουμε γενικά τα re-renders σε μια εφαρμογή. 👍
Γιατί όλα αυτά...?
Μπορεί να σκέφτεστε, γιατί να γράψουμε όλους αυτούς τους πολύπλοκους reducers και να χρησιμοποιήσουμε αυτή την παράξενη dispatch συνάρτηση για να κάνουμε απλά μερικά updates;
Ε λοπόν... αυτή είναι μια εύλογη ερώτηση, καθώς το παραπάνω παράδειγμα μπορούσε να γραφτεί απλώς με λίγες γραμμές χρησιμοποιώντας ένα useState
hook:
const Component = () => {
const [counter, setCounter] = useState(0); // initial counter state is 0
// then somewhere in the app...
return <div>
<button onClick={()=> setCounter(c => c + 1)}>Increase counter</button>
<button onClick={()=> setCounter(5)}>Set counter to 5</button>
</div>
}
Σαν γενικό κανόνα, η χρήση του useReducer
ταιριάζει καλύτερα όταν το state μας είναι έξαρτώμενο από προηγούμενα του states ή αν έχουμε πολύ complex states. Αλλά όπως είδατε και στο προηγούμενο παράδειγμα του μετρητή, φαίνεται ότι η χρήση του reducer ήταν overkill.
Κάθε εφαρμογή είναι διαφορετική και πρέπει να αναλύσουμε προσεκτικά τις απαιτήσεις για να καταστρώσουμε την υλοποίηση μας. Επίσης να έχετε κατα νου ότι υπάρχουν πολλά πακέτα εκεί έξω που διαχειρίζονται global state ή reducer-like state (όπως το υπέροχο zustand) απλώς με λίγες γραμμές κώδικα.
Για τους σκοπούς αυτού του tutorial, θα εστιάσουμε στις διαφορές του useState
και useReducer
και θα δημιουργήσουμε ένα απλό TODO app με δυο διαφορετικές υλοποιήσεις, μια με useState
και μια με useReducer
.
Μια εφαρμογή "Lorem ipsum TODO"
Η τελική μορφή της εφαρμογής θα είναι όπως φαίνεται στο GIF παρακάτω και θα προσφέρει στο χρήστη τη δυνατότητα να προσθέτει τυχαίες lorem ipsum προτάσεις και να τις επισημαίνει ως ολοκληρωμένες ή μή.
Υλοποίηση 1: Χρησιμοποιώντας useState
Στην πρώτη μας υλοποίηση θα χρησιμοποιήσουμε το useState
hook. Μπορείτε να δείτε τον κώδικα εδώ: https://codesandbox.io/s/lorem-ipsum-app-with-usestate-w60el
Δημιουργήσαμε 4 αρχεία για αυτή τη λύση:
App.tsx
: Το σημείο εισαγωγής της εφαρμογής όπου καλείται τοuseItems
hook.useItems.ts
: Το hook το οποίο εσωτερικά χρησιμοποιεί τοuseState
που διαχερίζεται το state των items (προσθήκη item, θέτοντας το completed κλπ).Actions.tsx
: Ένα component το οποίο κάνει render τα κουμπιά "Add item" και "Mark all completed".Items.tsx
: Το component που τελικά κάνει render τις προτάσεις μέσα σε boxes.
Ας ρίξουμε μια ματιά στο useItems
custom hook:
// useItems.ts
import { useState, useCallback } from "react";
export type ItemType = {
text: string;
completed?: boolean;
};
const useItems = () => {
const [items, setItems] = useState<ItemType[]>([]);
const addItem = useCallback(
(item: Pick<ItemType, "text">) =>
setItems((prevItems) => [item, ...prevItems]),
[]
);
const setItemCompleted = useCallback(
(itemIndex: number, completed: boolean) =>
setItems((prevItems) =>
prevItems.map((item, i) =>
i === itemIndex ? { ...item, completed } : item
)
),
[]
);
const toggleAllItemsCompleted = () => {
const areAllCompleted =
items.length > 0 &&
items.filter(({ completed }) => !completed).length === 0;
setItems((prevItems) =>
prevItems.map((item) => ({ ...item, completed: !areAllCompleted }));
);
};
return {
items,
addItem,
setItemCompleted,
toggleAllItemsCompleted
};
};
export default useItems;
Το useItems
hook επιστρέφει 4 πράγματα:
- Το
items
πίνακα που ουσιαστικά είναι ένας πίνακας από objects όπου κάθε object αντιπροσωπεύει ένα item. - Την συνάρτηση
addItem
που θα χρησιμοποιηθεί για να προσθέσει items στον πίνακα. - Την συνάρτηση
setItemCompleted
που θα χρησιμοποιηθεί για να επισημάνει ένα item ώς ολοκληρωμένο ή μη-ολοκληρωμένο. - Την συνάρτηση
toggleAllItemsCompleted
που μετατρέπει όλα τα items σε ολοκληρωμένα ή μή.
Το App
component (App.tsx) καλεί το useItems
hook και διανέμει τα κατάλληλα props σε όλα τα children components όπως φαίνεται παρακάτω.
Δεν χρειάζεται να δώσετε βάσει στο styling, απλώς επικεντρωθείτε στο functionality του κώδικα.
// App.tsx
import useItems from "./use-items";
import Items from "./Items";
import styled from "styled-components";
import Actions from "./Actions";
const AppContainer = styled("div")`
font-family: sans-serif;
margin: 16px;
`;
const TitleContainer = styled("div")`
display: flex;
align-items: center;
margin-bottom: 8px;
`;
const CompletedContainer = styled("div")`
text-align: right;
font-weight: 500;
`;
const Empty = styled("p")`
font-style: italic;
`;
export const App = () => {
const {
items,
addItem,
setItemCompleted,
toggleAllItemsCompleted
} = useItems();
const totalCompleted =
items.filter(({ completed }) => completed)?.length ?? 0;
return (
<AppContainer>
<TitleContainer>
<h3>Your Items</h3>
<Actions
totalItems={items.length}
addItem={addItem}
toggleAllItemsCompleted={toggleAllItemsCompleted}
/>
</TitleContainer>
<CompletedContainer>
Completed: {totalCompleted}/{items.length}
</CompletedContainer>
{items.length === 0 ? (
<Empty>Add items by clicking the button above...</Empty>
) : (
<Items items={items} setItemCompleted={setItemCompleted} />
)}
</AppContainer>
);
}
Τα components Actions
(Actions.tsx) και Items
(Items.tsx) χρησιμοποιούν τα δεχόμενα props ώστε να υλοποιήσουν τα onClick actions και/ή να κάνουν render τα items όπως φαίνεται παρακάτω:
// Actions.tsx
import styled from "styled-components";
import { ItemType } from "./use-items";
import { LoremIpsum } from "lorem-ipsum";
const lorem = new LoremIpsum({
wordsPerSentence: {
max: 6,
min: 2
}
});
type Props = {
totalItems: number;
addItem: (item: Pick<ItemType, "text">) => void;
toggleAllItemsCompleted: () => void;
};
const Container = styled("div")`
display: flex;
flex-wrap: wrap;
align-items: center;
margin: 0px 16px;
`;
const Button = styled("button")`
border-radius: 4px;
border: none;
font-size: 14px;
height: 30px;
background-color: #3453e5;
color: white;
padding: 0px 12px;
margin-right: 8px;
cursor: pointer;
:active {
opacity: 0.9;
transform: scale(1.05);
}
&.outlined {
border: 1px solid #3453e5;
background: none;
color: #3453e5;
}
`;
const Actions = (props: Props) => {
const { totalItems, addItem, toggleAllItemsCompleted } = props;
return (
<Container>
<Button
onClick={() =>
addItem({
text: lorem.generateWords()
})
}
>
Add Item
</Button>
{totalItems > 1 && (
<Button className="outlined" onClick={() => toggleAllItemsCompleted()}>
Toggle All
</Button>
)}
</Container>
);
};
export default Actions;
// Items.tsx
import { ItemType } from "./use-items";
import styled from "styled-components";
type Props = {
items: ItemType[];
setItemCompleted: (itemIndex: number, completed: boolean) => void;
};
const Container = styled("div")`
display: flex;
flex-wrap: wrap;
`;
const Item = styled("div")`
margin: 8px;
padding: 8px;
border: 1px solid #a7a7a7;
border-radius: 8px;
cursor: pointer;
color: #001a3a;
font-size: 18px;
text-transform: capitalize;
&:hover {
box-shadow: 0px 0px 3px 1px #b9b9b9;
}
&.completed {
opacity: 0.75;
text-decoration: line-through;
color: #9f9f9f;
}
`;
const Items = (props: Props) => {
const { items, setItemCompleted } = props;
return (
<Container>
{items.map(({ text, completed }, i) => (
<Item
key={i}
className={completed ? "completed" : ""}
onClick={() => setItemCompleted(i, !completed)}
>
{text}
</Item>
))}
</Container>
);
};
export default Items;
Βλέπουμε ότι η λύση με useState
δουλεύει μια χαρά. Έχουμε ένα custom hook που είναι υπεύθυνο να διαχειριστεί το state και επίσης κάνει expose τις συναρτήσεις addItem
, setItemCompleted
και toggleAllItemsCompleted
ώστε να μπορούν να τις χρησιμοποιήσουν components πιο χαμηλά στο δένδρο.
Υλοποίηση 2: Χρησιμοποιώντας useReducer
Για τη δεύτερη μας υλοποίηση θα χρησιμοποιήσουμε ένα useReducer
hook. Μπορείτε να δείτε τον κώδικα εδώ: https://codesandbox.io/s/lorem-ipsum-app-with-usereducer-h1c2s
Προφανώς το styling και η γενική δομή της εφαρμογή είναι ακριβώς η ίδια με την προηγούμενη υλοποίηση, για αυτό το λόγο θα αγνοήσουμε κώδικα που έχει να κάνει με το styling και θα εστιάσουμε στα σημαντικά σημεία.
Η κύρια αλλαγή είναι πώς υλοποιειται το useItems
hook (χρησιμοποιώντας useReducer) και πώς τα components αξιοποιούν τη συνάρτηση dispatch
ώστε να εκτελέσουν ενέργειες.
Ας ρίξουμε μια ματιά στο useItems
hook. Κατασκευάζουμε την συνάρτηση reducer παρέχοντας όλες τις λειτουργίες (actions) που θα έχει η εφαρμογή μας: ADD_ITEM, SET_ITEM_COMPLETED και TOGGLE_ALL_ITEMS_COMPLETED.
Τελικά το useReducer
θα επιστρέψει δυο πράγματα - το actual state
(που περιέχει τα items) και μια συνάρτηση dispatch
.
// useItems.ts
import { useReducer } from "react";
export type ItemType = {
text: string;
completed?: boolean;
};
type State = {
items: ItemType[];
};
export type Action =
| {
type: "ADD_ITEM";
data: {
item: Pick<ItemType, "text">;
};
}
| {
type: "SET_ITEM_COMPLETED";
data: { itemIndex: number; completed: boolean };
}
| { type: "TOGGLE_ALL_ITEMS_COMPLETED" };
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_ITEM": {
const { item } = action?.data;
return {
...state,
items: [item, ...state.items]
};
}
case "SET_ITEM_COMPLETED":
const { itemIndex, completed } = action?.data;
return {
...state,
items: state?.items.map((item, i) =>
i === itemIndex ? { ...item, completed } : item
)
};
case "TOGGLE_ALL_ITEMS_COMPLETED": {
const currentItems = state?.items ?? [];
const areAllCompleted =
currentItems.length > 0 &&
currentItems.filter(({ completed }) => !completed).length === 0;
return {
...state,
items: currentItems.map((item) => ({
...item,
completed: !areAllCompleted
}))
};
}
default:
throw new Error();
}
};
const useItems = () => {
const [state, dispatch] = useReducer(reducer, { items: [] });
return {
state,
dispatch
};
};
export default useItems;
To App
component χρειάζεται να διανείμει μόνο την συνάρτηση dispatch
στα child components, αντί να περάσει όλες τις ξεχωριστές συναρτήσεις όπως στο προηγούμενο παράδειγμα.
// App.tsx
import useItems from "./use-items";
import Items from "./Items";
import Actions from "./Actions";
import styled from "styled-components";
/*
...
styled components here
...
*/
export default function App() {
const { state, dispatch } = useItems();
const { items } = state;
const totalCompleted =
items.filter(({ completed }) => completed)?.length ?? 0;
return (
<AppContainer>
<TitleContainer>
<h3>Your Items</h3>
<Actions totalItems={items.length} dispatch={dispatch} />
</TitleContainer>
<CompletedContainer>
Completed: {totalCompleted}/{items.length}
</CompletedContainer>
{items.length === 0 ? (
<Empty>Add items by clicking the button above...</Empty>
) : (
<Items items={items} dispatch={dispatch} />
)}
</AppContainer>
);
}
Τα components Actions
και Items
χρειάζονται μόνο την συνάρτηση dispatch
για να εκτελέσουν ενέργειες.
Επίσης η TypeScript μας παρέχει με ωραία autocompletes για τη συνάρτηση dispatch εξαιτίας του γεγονότος ότι ο τύπος της συνάρτησης είναι React.Dispatch<Action>
. Μπορείτε να δείτε στο GIF παρακάτω πώς μπορούμε με εύκολο τρόπο να καταλάβουμε πώς δουλέυει η συγκεκριμένη dispatch και τι ορίσματα χρειάζεται για κάθε action.
// Actions.tsx
import styled from "styled-components";
import { Action } from "./use-items";
import { LoremIpsum } from "lorem-ipsum";
const lorem = new LoremIpsum({
wordsPerSentence: {
max: 6,
min: 2
}
});
type Props = {
totalItems: number;
dispatch: React.Dispatch<Action>;
};
/*
...
styled components here
...
*/
const Actions = (props: Props) => {
const { totalItems, dispatch } = props;
return (
<Container>
<Button
onClick={() =>
dispatch({
type: "ADD_ITEM",
data: {
item: {
text: lorem.generateWords()
}
}
})
}
>
Add Item
</Button>
{totalItems > 1 && (
<Button
className="outlined"
onClick={() => dispatch({ type: "TOGGLE_ALL_ITEMS_COMPLETED" })}
>
Toggle All
</Button>
)}
</Container>
);
};
export default Actions;
// Items.tsx
import { Action, ItemType } from "./use-items";
import styled from "styled-components";
type Props = {
items: ItemType[];
dispatch: React.Dispatch<Action>;
};
/*
...
styled components here
...
*/
const Items = (props: Props) => {
const { items, dispatch } = props;
return (
<Container>
{items.map(({ text, completed }, i) => (
<Item
key={i}
className={completed ? "completed" : ""}
onClick={() =>
dispatch({
type: "SET_ITEM_COMPLETED",
data: { itemIndex: i, completed: !completed }
})
}
>
{text}
</Item>
))}
</Container>
);
};
export default Items;
Συγκρίνοντας τις δυο υλοποιήσεις
useState | useReducer | |
---|---|---|
1 | Ανανεώνει το state με setState |
Ανανεώνει το state με dispatch συνάρτηση |
2 | Διανέμει όλες τις setState custom helper συναρτήσεις addItem , setItemCompleted , toggleAllItemsCompleted |
Χρειάζεται να διανείμει μόνο την συνάρτηση dispatch |
3 | Χρειάζεται να χρησιμοποιήσει useCallback στις συναρτήσεις (εάν θέλουμε να κάνουμε memoize) |
Η συνάρτηση dispatch είναι ήδη memoized |
4 | Δυσκολότερο να γίνει test αλλά ευκολότερο να γραφτεί | Ευκολότερο να γίνει test αλλά δυσκολότερο να γραφτεί· μπορούμε απλώς να τεστάρουμε τη συνάρτηση reducer που γίνεται expose |
Κατά τη γνώμη μου, πιστεύω ότι η υλοποίηση με το useReducer
ταιριάζει καλύτερα στη συγκεκριμένη εφαρμογή. Μπορούμε να δούμε ότι το app είναι πιο επεκτάσιμο (scalable) με τον reducer καθώς μπορούμε εύκολα να προσθέτουμε νέα actions και έπειτα τα components χρειάζονται απλώς να χρησιμοποιούν τη συνάρτηση dispatch για να ανανεώνουν το state. Παρέχει περισσότερο προβλεψιμότητα (predictability) και επεκτασιμότητα (scalability) στον κώδικα μας.
Με την useState
υλοποίηση θα έπρεπε να προσθέτουμε και άλλες custom συναρτήσεις (τις οποίες θα έπρεπε να ονοματίζουμε καταλλήλως - και είναι γνωστό ότι το να ονοματίζεις πράγματα είναι δύσκολο στην επιστήμη των υπολογιστών 🙃), και έπειτα να περνάμε αυτές τις συναρτήσεις για να χρησιμοποιηθούν στα components.
Επιπροσθέτως, εξαιτίας του γεγονότος ότι ο reducer είναι απλά μια αυτούσια συνάρτηση, μπορούμε εύκολα να τον τεστάρουμε ανεξάρτητα από το υπόλοιπο μέρος της εφαρμογής.
Έχοντας πει όλα αυτά, πρέπει να επισημάνω ότι υπάρχουν πολλοί τρόποι και υλοποιήσεις οι οποίες θα μπορούσαν να λύσουν το πρόβλημα. Κάποιος θα έλεγε ότι η χρήση context θα ήταν προτιμότερη ή ένα npm πακέτο που παρέχει εύκολα global state (ή reducer-like state) απλώς με λίγες γραμμές. Στο συγκεκριμένο tutorial ήθελα να επικεντρωθώ μόνο στις core "native" βιβλιοθήκες της React (useState και useReducer).
Θα κάνουμε και άλλο tutorial στο μέλλον όπου θα αναλύσουμε πώς μπορούμε να κάνουμε πράγματα με React context και άλλες βιβλιοθήκες.
Τέλος...
Σας ευχαριστώ που μείνατε ως το τέλος και να θυμάστε ότι... ανεξάρτητα από τί εργαλεία θα χρησιμοποιήσετε για να λύσετε ένα πρόβλημα, στο τέλος ο κώδικας πρέπει να είναι αναγνώσιμος (readable) και η υλοποίηση επεκτάσιμη (scalable), συντηρήσιμη (maintanable) και όσο το δυνατό λιγότερο πολύπλοκη (complex).
Τα λέμε αργότερα! 🙂
Εγγραφή
Εγγραφειτε στην λιστα
Εγγραφείτε με το e-mail σας για να σας στέλνω το υλικό μου. Δεν θα είναι spam, σας το υπόσχομαι! Μπορείτε να καταργήσετε την εγγραφή σας όποτε θέλετε.