Poznajemy dalej hook useState – tym razem stan jako tablica obiektów. Do dzieła.

Ok, początek naszego przykładu:

import { useState } from 'react';
import AddTodo from './AddTodo.js';
import TaskList from './TaskList.js';

let nextId = 3;
const initialTodos = [
  { id: 0, title: 'Buy milk', done: true },
  { id: 1, title: 'Eat tacos', done: false },
  { id: 2, title: 'Brew tea', done: false },
];

export default function TaskApp() {
  const [todos, setTodos] = useState(initialTodos);

Wyjaśnienie:

  • Initial todos są powyżej poziomu RFC bo inaczej tworzone by były od nowa przy każdym renderze-rerenderze (no chyba, że użylibyśmy useMemo)
  • nextId jest powyżej poziomu RFC, bo potrzebujemy zmiennej globalnej, a wrzucenie tego w RFC sprawiłoby tak samo, że nextId przy każdym renderze przypisywaną by miało wartość 3 a nie o to chodzi

Ok, lecimy dalej:

 function handleAddTodo(title) {
    setTodos([
      ...todos,
      {
        id: nextId++,
        title: title,
        done: false
      }
    ]);
  }

Stan jest niemutowalny i to jest jego niemutowalność w praktyce, tworzenie nowego obiektu z kopią zawartości starego plus nowym itemem, porównajmy to sobie do arr.push, to już wiemy czym jest mutowalność.

Teraz patent na niemutowalną edycję:

function handleChangeTodo(nextTodo) {
    setTodos(todos.map(t => {
      if (t.id === nextTodo.id) {
        return nextTodo;
      } else {
        return t;
      }
    }));
  }

Przyjmujemy nowy item, ustawiamy nowy obiekt (map nie jest metodą in-place, tworzy nowy) i tam iterujemy w map po todosach. Jeżeli id się zgadza z tym, który przekazaliśmy to jego zwracamy, jeżeli nie, to jest to inny todo, nieedytowany przez nas – return t.

Ok, teraz niemutowalne usuwanie:

function handleDeleteTodo(todoId) {
    setTodos(
      todos.filter(t => t.id !== todoId)
    );
  }

Niemutowalne usuwanie polega na stworzeniu kopii (filter nie jest in place) i umieszczeniu tam wszystkich innych todosów, poza tym, który nas nie interesuje, który ma być „usunięty”.

Teraz markup:

return (
    <>
      <AddTodo
        onAddTodo={handleAddTodo}
      />
      <TaskList
        todos={todos}
        onChangeTodo={handleChangeTodo}
        onDeleteTodo={handleDeleteTodo}
      />
    </>
  );
}

Warto to sobie uświadomić – funkcje z RFC będą tworzone od nowa przy każdym renderze, nawet jak się nie zmienią i jako propsy przekazywane do komponentów dzieci będą wymuszać re-render, nawet jak to nie jest potrzebne – ale techniki optymalizacji poznamy już niedługo.

Ok, teraz wykorzystanie tych funkcji:

import { useState } from 'react';

export default function AddTodo({ onAddTodo }) {
  const [title, setTitle] = useState('');
  return (
    <>
      <input
        placeholder="Add todo"
        value={title}
        onChange={e => setTitle(e.target.value)}
      />
      <button onClick={() => {
        setTitle('');
        onAddTodo(title);
      }}>Add</button>
    </>
  )
}

Proste, teraz reszta komponentów:

import { useState } from 'react';

export default function TaskList({
  todos,
  onChangeTodo,
  onDeleteTodo
}) {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <Task
            todo={todo}
            onChange={onChangeTodo}
            onDelete={onDeleteTodo}
          />
        </li>
      ))}
    </ul>
  );
}

function Task({ todo, onChange, onDelete }) {
  const [isEditing, setIsEditing] = useState(false);
  let todoContent;
  if (isEditing) {
    todoContent = (
      <>
        <input
          value={todo.title}
          onChange={e => {
            onChange({
              ...todo,
              title: e.target.value
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Save
        </button>
      </>
    );
  } else {
    todoContent = (
      <>
        {todo.title}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={e => {
          onChange({
            ...todo,
            done: e.target.checked
          });
        }}
      />
      {todoContent}
      <button onClick={() => onDelete(todo.id)}>
        Delete
      </button>
    </label>
  );
}

Jak przeanalizujemy sobie to wszystko to wrzucam z githuba WebDevSimplified hook useArray, który obrazuje świetnie niemutowalność i używanie tablic jako stanu:

import { useState } from "react"

export default function useArray(defaultValue) {
  const [array, setArray] = useState(defaultValue)

  function push(element) {
    setArray(a => [...a, element])
  }

  function filter(callback) {
    setArray(a => a.filter(callback))
  }

  function update(index, newElement) {
    setArray(a => [
      ...a.slice(0, index),
      newElement,
      ...a.slice(index + 1, a.length),
    ])
  }

  function remove(index) {
    setArray(a => [...a.slice(0, index), ...a.slice(index + 1, a.length)])
  }

  function clear() {
    setArray([])
  }

  return { array, set: setArray, push, filter, update, remove, clear }
}

Mamy co prawda nieco inne podejście do slice (autor zakłada, że to może być jakakolwiek tablica, niekoniecznie tablica obiektów, swoją drogą slice nie jest in-place, nie mylić ze splice), w remove mógł użyć filter, generalnie jednak są tu wszystkie koncepcje niemutowalności stanu jako obiektu złożonego…