Poznajemy nowy, bardzo potężny hook, bez którego trudno dziś sobie wyobrazić Reacta. Zaczynamy bardziej zaawansowane tematy, do dzieła!

Ok, useReducer:

  • Służy do zaawansowanego zarządzania równie zaawansowanym stanem
  • Przyjmuje funkcję reducer, która jest gdzieś wyżej RFC (albo importowana)
  • Przyjmuje initial state (też trzymamy poza RFC)
  • Zwraca state (nasz stan)
  • Zwraca dispatch (zaawansowany odpowiednik setState)

Przykład countera z dokumentacji Reacta, z użyciem useReducer:

import { useReducer } from 'react';

function reducer(state, action) {
  if (action.type === 'incremented_age') {
    return {
      age: state.age + 1
    };
  }
  throw Error('Unknown action.');
}

export default function Counter() {
  const [state, dispatch] = useReducer(reducer, { age: 42 });

  return (
    <>
      <button onClick={() => {
        dispatch({ type: 'incremented_age' })
      }}>
        Increment age
      </button>
      <p>Hello! You are {state.age}.</p>
    </>
  );
}

Funkcja reducer przyjmuje zawsze state i action. State to stan aktualny, na podstawie którego będziemy tworzyć nowy, action to obiekt przekazany do dispatch.

Action zazwyczaj pod type ma typ akcji zaś pod payload argument, zazwyczaj też reducer używa switcha na action.type, w caseach robi coś z payloadem według własnej logiki i zwraca nowy stan, który tak samo jak obiekt w useState jest niemutowalny, czyli tworzymy nowy stan w oparciu o kopiowanie starego z mergowaniem pewnych modyfikacji.

Bardzo prosto się robi dispatch, widać nic trudnego. Taki poważniejszy reducer wygląda tak:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      return {
        name: state.name,
        age: state.age + 1
      };
    }
    case 'changed_name': {
      return {
        name: action.nextName,
        age: state.age
      };
    }
  }
  throw Error('Unknown action: ' + action.type);
}

Dispatch bez payload i z payload (nazwane nextName tutaj):

function Form() {
  const [state, dispatch] = useReducer(reducer, { name: 'Taylor', age: 42 });
  
  function handleButtonClick() {
    dispatch({ type: 'incremented_age' });
  }

  function handleInputChange(e) {
    dispatch({
      type: 'changed_name',
      nextName: e.target.value
    });
  }

Niemutowalność:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // ✅ Instead, return a new object
      return {
        ...state,
        age: state.age + 1
      };
    }

Initial state i inne zmienne potrzebne globalnie powinny być trzymane globalnie:

let nextId = 3;
const initialTasks = [
  { id: 0, text: 'Visit Kafka Museum', done: true },
  { id: 1, text: 'Watch a puppet show', done: false },
  { id: 2, text: 'Lennon Wall pic', done: false }
];

Jeszcze pal licho, gdyby ten initial state był trzymany w RFC, choć będzie tworzony przy każdym renderze (mount i diff), ale nextId nie może być w RFC bo zawsze będzie 3 wynosić. Po każdym renderze.

Ok, teraz reducer:

function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      return [...tasks, {
        id: action.id,
        text: action.text,
        done: false
      }];
    }
    case 'changed': {
      return tasks.map(t => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case 'deleted': {
      return tasks.filter(t => t.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

Tak się bawimy z obiektami. Teraz tylko użycie reducera i odpowiednich dispatchy w RFC:

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(
    tasksReducer,
    initialTasks
  );

  function handleAddTask(text) {
    dispatch({
      type: 'added',
      id: nextId++,
      text: text,
    });
  }

  function handleChangeTask(task) {
    dispatch({
      type: 'changed',
      task: task
    });
  }

  function handleDeleteTask(taskId) {
    dispatch({
      type: 'deleted',
      id: taskId
    });
  }

  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask
        onAddTask={handleAddTask}
      />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

Zmienna nextId jest trzymana poza RFC, a zatem:

  • Można ją mutować
  • Nie jest przypisywana na 3 za każdym renderem, zatem nam to id zapewnia
  • Dopiero zresetowanie Reacta sprawi, że znowu będzie 3, unmount całej aplikacji i w prawdziwych projektach będziemy korzystać z baz danych albo api, ale do tego czasu wypadałoby rozumieć, co się w Reakcie dzieje…

Więcej Reacta niedługo…