Write reusable logic with custom React Hooks

Hooks are a relatively new concept in React, but they already solve a problem that is quite common in large React applications. Building performant, stateful interfaces in a reusable way is hard.

While React Contexts solve the problem of consuming and mutating state over numerous components without passing props along the tree, hooks solve the problem of writing stateful logic that is reusable.

Reusing logic without hooks

A common approach to reuse stateful logic is writing higher-order components (HOC) or by passing render props. HOC's require you to wrap your Components that share the logic into wrappers and render props require you to rewrite your render logic to handle the injections. This is fine in small and isolated components, but if you're managing global state that needs to be consumed and mutated all over your application, you create a lot of coupling around render logic.

By using Contexts you might have already noticed the useContext hook, which give access to state in the nearest parent Context.Provider, without writing a lot of boilerplate code per component.

Extracting logic from components

Lets say we have component where you can toggle between dark and light mode, like on this site. We need to track state changes for the toggle, but we would also like to store the user's preference in localStorage.

We can start writing our initial DarkModeToggle component to handle tracking the state. We also need some code to load the user's preference from localStorage and save the state to localStorage after the user toggles.

// components/DarkModeToggle.js

import { useState, useEffect } from 'react';

const DarkModeToggle = () => {
  const [enabled, setEnabled] = useState(null);
  const toggleDarkMode = () => setEnabled((current) => !current);

  useEffect(() => {
    if (localStorage) {
      const preference = localStorage.getItem('darkmodeEnabled');
      setEnabled(JSON.parse(preference));
    } else {
      setEnabled(false);
    }
  }, []);

  useEffect(() => {
    if (localStorage && enabled !== null) {
      localStorage.setItem('darkmodeEnabled', enabled);
    }
  }, [enabled]);

  return (
    <button onClick={handleToggle}>
      Toggle Dark Mode
    </button>
  );
};

export default DarkModeToggle;

This component works fine, but most of the boilerplate for storing the user's preferences is tightly coupled to the component. Imagine we also need a LanguageSwitch component, which also stores the preferences in localStorage.

To reuse the localStorage functionality, we can extract the logic into it's own hook. If we need the same stateful logic in multiple components, we can just use the hook. If there's a bug in your localStorage logic, you'll only have to fix the hook, instead of multiple components.

// hooks/preference.js

import { useState, useEffect } from 'react';

const usePreference = (key, initialState = null) => {
  const [state, setState] = useState(initialState);

  useEffect(() => {
    let preference = null;
    if (localStorage) {
      const storedPreference = localStorage.getItem(key);
      if (typeof storedPreference === 'string') {
        preference = JSON.parse(storedPreference);
        if (!!preference) {
          setState(preference);
        }
      }
    }
  }, []);

  useEffect(() => {
    if (localStorage) {
      localStorage.setItem(key, JSON.stringify(state));
    }
  }, [state]);

  return [state, setState];
};

export default usePreference;

By exposing the logic as the usePreference hook with the localStorage key and initialState arguments, we can easily reuse stateful logic in multiple components. Instead of writing code for storing user preferences into multiple components, we can reuse the logic in other components with minimal effort.

Lets refactor our DarkModeToggle component to use the new usePreference hook.

// components/DarkModeToggle.js

import usePreference from '../hooks/preference';

const DarkModeToggle = () => {
  const [enabled, setEnabled] = usePreference('darkmodeEnabled', false);
  const toggleDarkMode = () => setEnabled((current) => !current);

  return (
    <button onClick={handleToggle}>
      Toggle Dark Mode
    </button>
  );
};

export default DarkModeToggle;

Creating new components that reuse the same stateful logic requires minimal effort. Now lets create the LanguageSwitch component we mentioned earlier.

// components/LanguageSwitch.js

import usePreference from '../hooks/preference';

const LanguageSwitch = () => {
  const [language, setLanguage] = usePreference('language', 'en');
  const handleChange = ({ target: { value } }) => setLanguage(value);

  return (
    <select value={language} onChange={handleChange}>
      <option value="en">English</option>
      <option value="nl">Dutch</option>
    </select>
  );
};

export default LanguageSwitch;

Custom hooks are an effective way to write reusable stateful logic in a way that allows you to keep your components focussed on render logic.

By following this practice your code is less verbose, better readable and has a clear separation of concerns.

Stephan Lagerwaard
Stephan Lagerwaard
Frontend Engineer at Fiberplane.