Simple way to manage state in React with Context

August 2, 20206 min read1186 words

What we'll learn

We will learn how to use React's context API to manage state. Also, we'll see how to use useSWR hook from swr to manage async data from an API.

Our Requirements

  1. Data can come from synchronous or asynchronous calls. An API endpoint or a simple setState.
  2. Allow to update state data from the components that use it.
  3. No extra steps like actions, thunks.

Small introduction to swr

SWR (stale-while-revalidate) is a caching strategy where data is returned from a cache immediately and send fetch request to server. Finally, when the server response is available, get the new data with changes from the server as well as updating the cache.

Here we are talking about the swr library from vercel. It provides a hook useSWR which we will use to fetch data from GitHub API.

Head over to swr's docs to learn more. The API is small and easy.

Store

We need a top-level component where will maintain this global state. Let's call this component GlobalStateComponent. If you've used Redux, this can be your store.

We'll test with 2 types of data for better a understanding.

  • Users data coming from an API like GitHub which might not change pretty quickly.
  • A simple counter which increments count by 1 every second.
// global-store.jsx

const GlobalStateContext = React.createContext({
  users: [],
  count: 0,
});

export function GlobalStateProvider(props) {
  // we'll update here
  return <GlobalStateContext.Provider value={value} {...props} />;
}

// a hook which we are going to use whenever we need data from `GlobalStateProvider`

export function useGlobalState() {
  const context = React.useContext(GlobalStateContext);

  if (!context) {
    throw new Error("You need to wrap GlobalStateProvider.");
  }

  return context;
}

Now we need to use useSWR hook to fetch users data. Basic API for useSWR looks like this.

const { data, error, mutate } = useSWR("url", fetcher, [options]);

// url - an API endpoint url.
// fetcher - a function which takes the dependencies of first argument as parameters
// and returns a promise.
// options - Options for the hook. Configuration for this hook.

// data - response from the API request
// error - Error response from fetcher will be caught here.
// mutate - Update the cache and get new data from server.

We will use browser's built-in fetch API. You can use Axios or any other library you prefer.

const fetcher = (url) => fetch(url).then((res) => res.json());

With this, our complete useSWR hook looks like this.

const { data, error, mutate } = useSWR(`https://api.github.com/users`, fetcher);

And, we need a setState with count and a setInterval which updates the count every second.

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

React.useEffect(() => {
  interval.current = setInterval(() => {
        setCount(count => count + 1);
  }, 1000);

  return () => {
    interval.current && clearInterval(interval.current);
  }
}, []);
...

A context provider takes a value prop for the data. Our value will be both user related data and count.

If we put all these little things together in a global-store.jsx file, it looks like this.

// global-store.jsx

const GlobalStateContext = React.createContext({
  users: [],
  mutateUsers: () => {},
  error: null,
  count: 0,
});

export function GlobalStateProvider(props) {
  const { data: users, error, mutate: mutateUsers } = useSWR(
    `https://api.github.com/users`,
    fetcher
  );
  const [count, setCount] = React.useState(0);
  const interval = React.useRef();

  React.useEffect(() => {
    interval.current = setInterval(() => {
      setCount((count) => count + 1);
    }, 1000);

    return () => {
      interval.current && clearInterval(interval.current);
    };
  }, []);

  const value = React.useMemo(() => ({ users, error, mutateUsers, count }), [
    users,
    error,
    mutateUsers,
    count,
  ]);

  return <GlobalStateContext.Provider value={value} {...props} />;
}

// a hook to use whenever we need to consume data from `GlobalStateProvider`.
// So, We don't need React.useContext everywhere we need data from GlobalStateContext.

export function useGlobalState() {
  const context = React.useContext(GlobalStateContext);

  if (!context) {
    throw new Error("You need to wrap GlobalStateProvider.");
  }

  return context;
}

How to use it

Wrap your top-level component with GlobalStateProvider.

// app.jsx
export default function App() {
  return <GlobalStateProvider>//...</GlobalStateProvider>;
}

Let's have two components, one consumes users data and another one needs counter.

We can use useGlobalState hook we created in both of them to get users and count.

// users.jsx

export default function Users() {
  const { users, error } = useGlobalState();

  if (!users && !error) {
    return <div>Loading...</div>;
  }

  return <ul>...use `users` here</ul>;
}
// counter.jsx

export default function Counter() {
  const { count } = useGlobalState();

  return <div>Count: {count}</div>;
}
// app.jsx

export default function App() {
  return (
    <GlobalStateProvider>
      <Counter />
      <Users />
    </GlobalStateProvider>
  );
}

That's it. Now you'll see both Counter and Users.

The codesandox link: codesandbox

But, Wait

If you put a console.log in both Users and Counter components, you'll see even if only count updated, Users component also renders.

The fix is simple. Extract users in a component between App and Users, and pass users as a prop to Users component, and wrap Users in React.memo

// app.jsx

export default function App() {
  return (
    <GlobalStateProvider>
      <Counter />
-     <Users />
+     <UserWrapper />
    </GlobalStateProvider>
  )
}
// user-wrapper.jsx

export default function UserWrapper() {
  const { users, error } = useGlobalState();
  return <Users users={users} error={error} />;
}
// users.jsx

- export default function Users() {
+ const Users = React.memo(function Users({users, error}) {
- const {users, error} = useGlobalState();

  if (!users && !error) {
    return <div>Loading...</div>;
  }

  return (
    <ul>
        ...use users here
    </ul>
  )
});

export default Users;

Now check the console.log again. You should see only Counter component rendered.

The finished codesandbox link: codesandbox

How to force-update users

Our second requirement was to update the state from any component.

In the same above code, if we pass setCounter and mutateUsers in the context provider's value prop, you can use those functions to update the state.

setCounter will update the counter and mutateUsers will resend the API request and returns new data.

You can use this method to maintain any synchronous, asynchronous data without third-party state management libraries.

Closing Notes

  • Consider using useReducer instead of useState if you end up with too many setStates in global state. A good use case will be if you are storing a large object instead of a single value like count above. Splitting up that object in multiple setState means any change in each of them will re-render all the components using your context provider. It'll get annyoing to keep track and bring in React.memo for every little thing.
  • react-query is another solid library as an alternative to swr.
  • Redux is still doing great for state management. The new redux-toolkit amazingly simplifies Redux usage. Check it out.
  • Have an eye on recoil, A new state management library with easy sync, async state support. I didn't use it on a project yet. I'll definitely try it soon.

Thank you and have a great day. 😀 👋