12 Φεβ, 2022

React: useState vs useReducer

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 προτάσεις και να τις επισημαίνει ως ολοκληρωμένες ή μή.

Our TODO App

Υλοποίηση 1: Χρησιμοποιώντας useState

Στην πρώτη μας υλοποίηση θα χρησιμοποιήσουμε το useState hook. Μπορείτε να δείτε τον κώδικα εδώ: https://codesandbox.io/s/lorem-ipsum-app-with-usestate-w60el

Δημιουργήσαμε 4 αρχεία για αυτή τη λύση:

  1. App.tsx: Το σημείο εισαγωγής της εφαρμογής όπου καλείται το useItems hook.
  2. useItems.ts: Το hook το οποίο εσωτερικά χρησιμοποιεί το useState που διαχερίζεται το state των items (προσθήκη item, θέτοντας το completed κλπ).
  3. Actions.tsx: Ένα component το οποίο κάνει render τα κουμπιά "Add item" και "Mark all completed".
  4. 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 πράγματα:

  1. Το items πίνακα που ουσιαστικά είναι ένας πίνακας από objects όπου κάθε object αντιπροσωπεύει ένα item.
  2. Την συνάρτηση addItem που θα χρησιμοποιηθεί για να προσθέσει items στον πίνακα.
  3. Την συνάρτηση setItemCompleted που θα χρησιμοποιηθεί για να επισημάνει ένα item ώς ολοκληρωμένο ή μη-ολοκληρωμένο.
  4. Την συνάρτηση 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.

Dispatch TS types autocomplete

// 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).

Τα λέμε αργότερα! 🙂

Loading Comments...