Summoning builders…Compiling dreams…Polishing pixels…Aligning the stars to your roadmap…Brewing the backlog…Forging gold from ideas…Charting the constellations…Waking the render spirits…

    Insights/Software Engineering/Use React Hooks For More Efficient State Management

    Use React Hooks For More Efficient State Management

    Marco Lopes
    November 05, 2019
    2k views
    Software Engineering
    hooks
    react
    state-management
    tutorials
    Use React Hooks For More Efficient State Management

    React components often begin with one small piece of state: an open menu, a search term, or a selected item. As features grow, that state can become scattered across event handlers, lifecycle methods, and components that know too much about one another.

    React Hooks, introduced in React 16.8, offer a more direct way to organize this logic. Function components can own state, synchronize with external systems, and reuse behavior without introducing a class or another wrapper component. The payoff is not automatically faster rendering; it is clearer ownership, more predictable updates, and less duplicated stateful code.

    This tutorial builds a small task list to show when to use useState, when to reach for useReducer, and how custom Hooks and Context fit into the picture.

    This guide reflects React in 2019; tools and interfaces may have changed since then.

    Understand what a Hook actually is

    A function that returns JSX is a function component, not necessarily a Hook:

    function Greeting() {
      return <div>Hello world!</div>;
    }
    

    A Hook is a function that lets a component use React features such as state or effects. Built-in Hooks include useState, useReducer, useEffect, and useRef. Custom Hooks combine these primitives into reusable behavior and conventionally have names beginning with use.

    You do not need a special module loader, a global reference, or a new ref object for every update. React associates Hook calls with a component instance and preserves their values between renders.

    There are two essential rules:

    • Call Hooks at the top level, not inside conditions, loops, or nested functions.
    • Call Hooks only from React function components or other Hooks.

    These rules keep Hook calls in a consistent order. Enable eslint-plugin-react-hooks to catch violations and help check effect dependencies.

    1. Start with local state using useState

    Before adding a state-management library, ask whether the state belongs to one component. Input values, toggles, and temporary selections usually do.

    Here is a task input using useState:

    import React, { useState } from 'react';
    
    function TaskInput({ onAdd }) {
      const [text, setText] = useState('');
    
      function handleSubmit(event) {
        event.preventDefault();
        const title = text.trim();
    
        if (!title) return;
    
        onAdd(title);
        setText('');
      }
    
      return (
        <form onSubmit={handleSubmit}>
          <label htmlFor="task-title">New task</label>
          <input
            id="task-title"
            value={text}
            onChange={event => setText(event.target.value)}
          />
          <button type="submit">Add task</button>
        </form>
      );
    }
    

    The input owns its draft because no other component needs it. The parent receives only a completed title. This keeps transient editing state out of the task collection and avoids updating a wider component tree on every keystroke.

    Choose state boundaries deliberately

    Unlike a class component's setState, a useState setter replaces its value rather than merging object properties.

    const [settings, setSettings] = useState({
      sort: 'newest',
      showCompleted: true
    });
    
    function changeSort(sort) {
      setSettings(previous => ({ ...previous, sort }));
    }
    

    Store unrelated values separately when they change independently. Keep values together when updates must preserve a shared invariant. Neither one giant object nor dozens of individual setters should be the default for every component.

    2. Make updates predictable and immutable

    When an update depends on previous state, use the functional updater form:

    setCount(previous => previous + 1);
    

    This tells React how to calculate the next value from the pending state. It avoids relying on the value captured by a particular render, especially when several updates are queued.

    Do not mutate arrays or objects stored in state:

    // Avoid: changes the existing array.
    // tasks.push(newTask);
    // setTasks(tasks);
    
    // Prefer: produces a new array.
    setTasks(previous => [...previous, newTask]);
    

    Immutability makes changes explicit and helps React and memoized components compare values correctly. Use map to replace an item and filter to remove one.

    Also remember that setters schedule an update. Reading a state variable immediately after calling its setter still gives you the value from the current render, not the next one.

    3. Move related transitions into useReducer

    useState is a good starting point, but a collection with add, toggle, and remove behavior has several related transitions. A reducer puts those rules in one place.

    import React, { useReducer } from 'react';
    
    function taskReducer(tasks, action) {
      switch (action.type) {
        case 'added':
          return [...tasks, action.task];
    
        case 'toggled':
          return tasks.map(task =>
            task.id === action.id
              ? { ...task, completed: !task.completed }
              : task
          );
    
        case 'removed':
          return tasks.filter(task => task.id !== action.id);
    
        default:
          throw new Error('Unknown task action: ' + action.type);
      }
    }
    
    function TaskApp() {
      const [tasks, dispatch] = useReducer(taskReducer, []);
    
      function addTask(title) {
        dispatch({
          type: 'added',
          task: {
            id: createTaskId(),
            title,
            completed: false
          }
        });
      }
    
      const remaining = tasks.filter(task => !task.completed).length;
    
      return (
        <section>
          <TaskInput onAdd={addTask} />
          <p>{remaining} tasks remaining</p>
          <ul>
            {tasks.map(task => (
              <li key={task.id}>
                <label>
                  <input
                    type="checkbox"
                    checked={task.completed}
                    onChange={() =>
                      dispatch({ type: 'toggled', id: task.id })
                    }
                  />
                  {task.title}
                </label>
                <button
                  type="button"
                  onClick={() =>
                    dispatch({ type: 'removed', id: task.id })
                  }
                >
                  Remove
                </button>
              </li>
            ))}
          </ul>
        </section>
      );
    }
    
    function createTaskId() {
      return Date.now().toString(36) + Math.random().toString(36).slice(2);
    }
    

    This ID generator is suitable for a small demonstration, not a uniqueness guarantee. Production applications should use a suitable ID library or server-issued identifiers. Stable IDs also make better list keys than array indexes when items can be removed or reordered.

    Keep reducers pure

    A reducer should calculate state from its arguments without modifying them. Do not fetch data, write to storage, or generate IDs inside it. Generate an ID before dispatching so the same state and action always produce the same result.

    Notice that remaining is calculated rather than stored. Keeping both tasks and a separate count would create two values that must stay synchronized. Derive values whenever practical.

    4. Extract reusable behavior into a custom Hook

    Custom Hooks reuse stateful logic, not a shared state instance. If two components call the same Hook, each gets its own state unless the Hook explicitly connects to a shared provider or external store.

    For the task list, we can extract the reducer setup and add browser persistence:

    import { useEffect, useReducer } from 'react';
    
    const STORAGE_KEY = 'kordspace-tasks';
    
    function readTasks() {
      if (typeof window === 'undefined') return [];
    
      try {
        const stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY));
        const valid = Array.isArray(stored) && stored.every(task =>
          task &&
          typeof task.id === 'string' &&
          typeof task.title === 'string' &&
          typeof task.completed === 'boolean'
        );
    
        return valid ? stored : [];
      } catch (error) {
        return [];
      }
    }
    
    function useTasks() {
      const [tasks, dispatch] = useReducer(taskReducer, null, readTasks);
    
      useEffect(() => {
        try {
          window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
        } catch (error) {
          // Keep the app usable if storage is unavailable or full.
        }
      }, [tasks]);
    
      return [tasks, dispatch];
    }
    

    Replace the reducer call in TaskApp with useTasks(). The third argument to useReducer initializes state from storage when the component mounts; it does not reread storage on every render.

    The effect synchronizes committed task state with an external system. Its dependency array includes tasks, so it runs after the initial render and after that value changes.

    This example targets a client-rendered application. Server-rendered applications need a deliberate hydration strategy because the server cannot read browser storage. It also does not synchronize multiple tabs or multiple callers of useTasks. For valuable data, surface persistence failures instead of silently ignoring them.

    5. Share state only where it is needed

    If sibling components need the same tasks, lift the useTasks call into their closest common parent and pass state and callbacks through props.

    When deeply nested components need the same state, Context can reduce repetitive prop forwarding:

    import React, { createContext, useContext } from 'react';
    
    const TasksContext = createContext(undefined);
    
    function TasksProvider({ children }) {
      const value = useTasks();
    
      return (
        <TasksContext.Provider value={value}>
          {children}
        </TasksContext.Provider>
      );
    }
    
    function useSharedTasks() {
      const value = useContext(TasksContext);
    
      if (value === undefined) {
        throw new Error('useSharedTasks requires TasksProvider');
      }
    
      return value;
    }
    

    Wrap the relevant subtree in TasksProvider, then call useSharedTasks in its consumers. For this version, replace TaskApp's local useTasks call with useSharedTasks; otherwise it would create an independent collection.

    Context distributes a value; it does not automatically optimize subscriptions. Consumers update when the provider value changes. For larger applications, separate unrelated contexts or split state and dispatch contexts so dispatch-only consumers need not subscribe to task changes. Hooks do not make established state-management libraries obsolete.

    Avoid common Hook pitfalls

    Confusing refs with state

    useRef returns a stable object across renders. Changing its .current property does not trigger rendering. Use refs for DOM access or mutable values such as timer identifiers, not for task data that must update the screen.

    Using effects for every calculation

    Do not copy derived values into state through an effect. Calculate filtered tasks or counts during rendering. This avoids an unnecessary synchronization step and an additional update.

    Effects are appropriate for synchronization such as subscriptions and storage. Return cleanup functions when an effect installs listeners or starts subscriptions.

    Hiding stale closures with empty dependencies

    An effect or callback captures values from the render that created it. An empty dependency array does not make those values stay current. Include reactive dependencies and restructure the logic if the effect runs too often; do not suppress warnings just to make them disappear.

    Memoizing before measuring

    useMemo can cache an expensive calculation, and useCallback can stabilize a function reference. Neither prevents rendering by itself, and both add complexity. Start with good state boundaries, then use the React DevTools Profiler to identify expensive updates before adding memoization.

    Takeaway

    Efficient state management starts with fewer sources of truth and clear ownership. Use useState for simple local values, useReducer for related transitions, custom Hooks for reusable behavior, and Context when shared access justifies it. Keep reducers pure, derive what you can, and reserve effects for synchronization. Hooks then become a way to simplify your application's behavior, not merely a shorter way to write components.

    Conversation (0)

    No comments yet. Start the conversation.

    Join the conversation

    Create a free member account to comment.