Redux Who? Handle your own state instead

State management libraries for React are a dime a dozen. Redux. Recoil. MobX. Choosing one is a hard task for even the most experienced developers - but have you ever considered skipping the choice altogether?

Some apps need advanced state management. They really do! When you’re handling hundreds of different variables that change as a reaction to other state values, things will definitely be easier to scale when you’re using a library like Recoil. However, I’m willing to venture that your app doesn’t need it.

In this article, I will present how you can handle state, even at scale, by co-locating it and keeping it simple.

The basics of state handling

With all the Reduxes of the world, it’s easy to forget that React ships with its own state management system! Let’s look at how it looks.

const [count, setCount] = React.useState(0);

If you need to handle any kind of value that changes over time, you add a call to the useState hook. This works great for simple values that change one at a time.

Memoize instead

Some data doesn’t even need to live in state! Take this example:

const [filter, setFilter] = React.useState('none');
const [filteredItems, setFilteredItems] = React.useState(props.items);

function onFilterChange(newFilter) {
  setFilter(newFilter);
  setFilteredItems(
    props.items.filter(item => item.someProperty === newFilter)
  );
}

Here, there’s no real reason for keeping the filtered items in state! Instead, we could memoize the filtering, and only recompute the new filtered items whenever the filter (or items to filter) changes.

const [filter, setFilter] = React.useState('none');
const filteredItems = React.useMemo(() => 
  items.filter(item => item.someProperty === newFilter), 
  [props.items, filter]
);
function onFilterChange(newFilter) {
  setFilter(newFilter);
}

Use a reducer

If you have some more advanced state, you can also use the more flexible useReducer hook!

const [request, dispatch] = React.useReducer((state, action) => {
  switch (action.type) {
    case 'loading': return { state: 'loading' };
    case 'success: return { state: 'success', data: action.data };
    case 'error': return { state: 'error', error: action.error };
    case 'reset': return { state: 'idle' };
    default: throw new Error(`Unknown action ${action.type}`);
  }
}, { state: 'idle' });

You can do the same with several useState calls, but when several changes happen at the same time, I tend to use useReducer instead. Luckily, that doesn’t happen too often.

Share state across components

Now, if you need this state in several places, you need to “lift it up” to the first common ancestor component.

If there aren’t lots of component layers between them, you can simply pass down the value and the updater function as props.

const CommonAncestor = () => {
  const [filter, setFilter] = React.useState('none');
  return (
    <div>
      <FilterSelector filter={filter} setFilter={setFilter} />
      <FilteredItems filter={filter} />
    </div>
  );
};

Use contexts when appropriate.

If there are tons of layers between the first common ancestor and your components, or if you’re creating reusable, generic components where you can’t apply props directly, you want to create a context.

In the case you need to create context, a Provider component and a hook to consume the context need to be added.

const FilterContext = React.createContext();
const FilterProvider = (props) => {
  const [filter, setFilter] = React.useState('none');
  return (
    <FilterContext.Provider value={{ filter, setFilter }} {...props} />
  );
};
const useFilter = () => {
  const context = React.useContext(FilterContext);
  if (!context) {
    throw new Error("Wrap your component in a FilterProvider");
  }
  return context;
};

Now, we can change your common ancestor component to look like this:

const CommonAncestor = () => {
  return (
    <FilterProvider>
      <FilterSelector />
      <FilteredItems />
    </FilterProvider>
  );
};

We have moved all the filter related code into the FilterProvider, and remove all props passed to the FilterSelector and FilteredItems. The latter two can now look like this:

const FilterSelector = () => {
  const { filter, setFilter } = useFilter();
  return (...);
};

const FilteredItems = () => {
  const { filter } = useFilter();
  const items = getItemsSomehow();
  const filteredItems = React.useMemo(
    () => items.filter(item => item.someProperty === filter), 
    [filter, items]
  );

  return (...)
};

Open Source Session Replay

Debugging a web application in production may be challenging and time-consuming. OpenReplay is an Open-source alternative to FullStory, LogRocket and Hotjar. It allows you to monitor and replay everything your users do and shows how your app behaves for every issue.
It’s like having your browser’s inspector open while looking over your user’s shoulder.
OpenReplay is the only open-source alternative currently available.

Happy debugging, for modern frontend teams - Start monitoring your web app for free.

Most state isn’t global

A mistake I see a lot of people doing in the wild, is creating a lot of global contexts when they don’t really need it. It’s an easy mistake to make - especially if you’re used to the Redux way of thinking. It’s kind of annoying though, because you end up with this huge nesting and I’m here to tell you that you most likely don’t need it.

Server cache isn’t state.

In my opinion, saving - or caching - data from your server isn’t state. It’s cache. Therefore, I think you should use some kind of data fetching hook (like useSWR or react-query) that handles all of that for you. You could write your own, of course, and a very simple version of that would look something like this:

const cache = {};
const useFetch = (key, fetcher) => {
  const [request, dispatch] = React.useReducer(
    requestReducerFromEarlier, 
    { state: 'idle' },
  );
  const goFetch = async () => {
    try {
      dispatch({ type: 'loading' });
      const result = await fetcher(key);
      dispatch({ type: 'success', data: result });
      cache[key] = result;
    } catch (e) {
      dispatch({ type: 'error', error: e });
    }
  };
  if (cache[key]) {
    goFetch();
    return { data: cache[key], ...request };
  }
  return request;
}

With this (or, more likely, a library that does a better job at the same), you can remove most of these global contexts in a single swoop, and co-locate the data requirements with where it’s used.

Co-locate when you can

And speaking of - make sure to co-locate all state with where its used. Avoid using global contexts whenever you can. When you do this, you make sure you’ll delete all related state when you delete some feature. It’s easier to find, and there’s much less magic to understand.

In conclusion

Handle your own state when possible. That’s it, that’s the conclusion.
Also, stop defaulting to use hard-to-learn state management libraries, and use the tools React provides out of the box.

26