Skip to content

Managing State

Fresh

As your application grows, it helps to be more intentional about how state is organized and how data flows between components.

Reacting to Input with State

Think of UI declaratively: describe what the UI should look like for each visual state, rather than imperatively manipulating the DOM.

flowchart LR
    A[Empty] --> B[Typing]
    B --> C[Submitting]
    C --> D[Success]
    C --> E[Error]
    E --> B
jsx
import { useState } from 'react';

export default function Form() {
  const [status, setStatus] = useState('typing'); // 'typing' | 'submitting' | 'success'
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

  if (status === 'success') {
    return <h1>Correct!</h1>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        value={answer}
        onChange={e => setAnswer(e.target.value)}
        disabled={status === 'submitting'}
      />
      <button disabled={answer.length === 0 || status === 'submitting'}>
        Submit
      </button>
      {error !== null && <p className="Error">{error.message}</p>}
    </form>
  );
}

Choosing the State Structure

Five principles for structuring state:

  1. Group related state. If two state variables always change together, unify them.
  2. Avoid contradictions. Don't have isSending and isSent both be true.
  3. Avoid redundant state. If you can compute it from props or other state, don't store it.
  4. Avoid duplication. Don't store the same data in multiple state variables.
  5. Avoid deeply nested state. Flat/normalized state is easier to update.
jsx
// BAD: redundant state
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState(''); // redundant!

// GOOD: compute derived values
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = firstName + ' ' + lastName; // computed

Sharing State Between Components

Lift state up to the closest common parent:

jsx
import { useState } from 'react';

function Panel({ title, children, isActive, onShow }) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? <p>{children}</p> : <button onClick={onShow}>Show</button>}
    </section>
  );
}

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <>
      <Panel title="About" isActive={activeIndex === 0} onShow={() => setActiveIndex(0)}>
        Content for About panel.
      </Panel>
      <Panel title="Etymology" isActive={activeIndex === 1} onShow={() => setActiveIndex(1)}>
        Content for Etymology panel.
      </Panel>
    </>
  );
}

Preserving and Resetting State

React preserves state for components that are rendered at the same position in the UI tree:

jsx
// Same component at same position = state preserved
{isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}

// Different components at same position = state destroyed
{isPaused ? <p>See you later!</p> : <Counter />}

To reset state at the same position, give it a different key:

jsx
{isPlayerA
  ? <Counter key="Taylor" person="Taylor" />
  : <Counter key="Sarah" person="Sarah" />
}

Key Takeaway

Same component + same position = preserved state. Different key = reset state. React sees position in the tree, not in JSX.

Extracting State Logic into a Reducer

For complex state logic, extract it into a reducer function:

jsx
import { useReducer } from 'react';

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 => t.id === action.task.id ? action.task : t);
    case 'deleted':
      return tasks.filter(t => t.id !== action.id);
    default:
      throw Error('Unknown action: ' + action.type);
  }
}

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

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

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

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

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

useState vs useReducer

useStateuseReducer
Code sizeLess upfrontMore upfront (reducer + dispatch)
ReadabilitySimple updates are clearComplex updates are clearer
DebuggingHarder to traceAdd console.log in reducer
TestingN/AReducer is a pure function, easy to test

Passing Data Deeply with Context

Context lets a parent provide data to the entire tree below it without passing props:

jsx
import { createContext, useContext, useState } from 'react';

const LevelContext = createContext(1);

function Heading({ children }) {
  const level = useContext(LevelContext);
  const Tag = 'h' + level;
  return <Tag>{children}</Tag>;
}

function Section({ children }) {
  const level = useContext(LevelContext);
  return (
    <section className="section">
      <LevelContext.Provider value={level + 1}>
        {children}
      </LevelContext.Provider>
    </section>
  );
}

Context Use Cases

  • Theming (dark mode)
  • Current account / logged-in user
  • Routing (most routers use context internally)
  • Managing complex state (often combined with a reducer)

Scaling Up with Reducer and Context

Combine useReducer with Context to manage state across the tree:

jsx
import { createContext, useContext, useReducer } from 'react';

const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
  return (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        {children}
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}

export function useTasks() {
  return useContext(TasksContext);
}

export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

This pattern moves all state logic into a single file and keeps components clean.