Navigate back to the homepage
BLOG
Try NowLogin
Back

How to Use Async Actions for Global State With React Hooks and Context

Daishi Kato
November 4th, 2020 · 3 min read

Introduction

I have been developing React Tracked, which is a library for global state with React Hooks and Context.

https://react-tracked.js.org

This is a small library and focuses on only one thing. It optimizes re-renders using state usage tracking. More technically, it uses Proxies to detect the usage in render, and only triggers re-renders if necessary.

Because of that, the usage of React Tracked is very straightforward. It is just like the normal useContext. Here’s an example.

1const Counter = () => {
2 const [state, setState] = useTracked();
3 // The above line is almost like the following.
4 // const [state, setState] = useContext(Context);
5 const increment = () => {
6 setState(prev => ({ ...prev, count: prev.count + 1 }));
7 };
8 return (
9 <div>
10 {state.count}
11 <button onClick={increment}>+1</button>
12 </div>
13 );
14};

For a concrete example, please check out “Getting Started” in the doc.

Now, because React Tracked is a wrapper around React Hooks and Context, it doesn’t support async actions natively. This post shows some examples how to handle async actions. It’s written for React Tracked, but it can be used without React Tracked.

The example we use is a simple data fetching from a server. The first pattern is without any libraries, and uses custom hooks. The rest is using three libraries, one of which is my own.

Custom hooks without libraries

Let’s look at a native solution. We define a store at first.

1import { createContainer } from 'react-tracked';
2
3const useValue = () => useState({ loading: false, data: null });
4const { Provider, useTracked } = createContainer(useValue);

This is one of the patterns to create a store (container) in React Tracked. Please check out the recipes for other patterns.

Next, we create a custom hook.

1const useData = () => {
2 const [state, setState] = useTracked();
3 const actions = {
4 fetch: async (id) => {
5 setState(prev => ({ ...prev, loading: true }));
6 const response = await fetch(`https://reqres.in/api/users/${id}?delay=1`);
7 const data = await response.json();
8 setState(prev => ({ ...prev, loading: false, data }));
9 },
10 };
11 return [state, actions];
12};

This is a new hook based on useTracked and it returns state and actions. You can invoke action.fetch(1) to start fetching.

Note: Consider wrapping with useCallback if you need a stable async function.

React Tracked actually accepts a custom hook, so this custom hook can be embedded in the container.

1import { createContainer } from 'react-tracked';
2
3const useValue = () => {
4 const [state, setState] = useState({ loading: false, data: null });
5 const actions = {
6 fetch: async (id) => {
7 setState(prev => ({ ...prev, loading: true }));
8 const response = await fetch(`https://reqres.in/api/users/${id}?delay=1`);
9 const data = await response.json();
10 setState(prev => ({ ...prev, loading: false, data }));
11 },
12 };
13 return [state, actions];
14};
15
16const { Provider, useTracked } = createContainer(useValue);

Try the working example.

https://codesandbox.io/s/hungry-nightingale-qjeis

useThunkReducer

react-hooks-thunk-reducer provides a custom hook useThunkReducer. This hook returns dispatch which accepts a thunk function.

The same example can be implemented like this.

1import { createContainer } from 'react-tracked';
2import useThunkReducer from 'react-hook-thunk-reducer';
3
4const initialState = { loading: false, data: null };
5const reducer = (state, action) => {
6 if (action.type === 'FETCH_STARTED') {
7 return { ...state, loading: true };
8 } else if (action.type === 'FETCH_FINISHED') {
9 return { ...state, loading: false, data: action.data };
10 } else {
11 return state;
12 }
13};
14
15const useValue = () => useThunkReducer(reducer, initialState);
16const { Provider, useTracked } = createContainer(useValue);

Invoking an async action would be like this.

1const fetchData = id => async (dispatch, getState) => {
2 dispatch({ type: 'FETCH_STARTED' });
3 const response = await fetch(`https://reqres.in/api/users/${id}?delay=1`);
4 const data = await response.json();
5 dispatch({ type: 'FETCH_FINISHED', data });
6};
7
8dispatch(fetchData(1));

It should be familiar to redux-thunk users.

Try the working example.

https://codesandbox.io/s/crimson-currying-og54c

useSagaReducer

use-saga-reducer provides a custom hook useSagaReducer. Because this library uses External API, you can use redux-saga without Redux.

Let’s implement the same example again with Sagas.

1import { createContainer } from 'react-tracked';
2import { call, put, takeLatest } from 'redux-saga/effects';
3import useSagaReducer from 'use-saga-reducer';
4
5const initialState = { loading: false, data: null };
6const reducer = (state, action) => {
7 if (action.type === 'FETCH_STARTED') {
8 return { ...state, loading: true };
9 } else if (action.type === 'FETCH_FINISHED') {
10 return { ...state, loading: false, data: action.data };
11 } else {
12 return state;
13 }
14};
15
16function* fetcher(action) {
17 yield put({ type: 'FETCH_STARTED' });
18 const response = yield call(() => fetch(`https://reqres.in/api/users/${action.id}?delay=1`));
19 const data = yield call(() => response.json());
20 yield put({ type: 'FETCH_FINISHED', data });
21};
22
23function* fetchingSaga() {
24 yield takeLatest('FETCH_DATA', fetcher);
25}
26
27const useValue = () => useSagaReducer(fetchingSaga, reducer, initialState);
28const { Provider, useTracked } = createContainer(useValue);

Invoking it is simple.

1dispatch({ type: 'FETCH_DATA', id: 1 });

Notice the similarity and the difference. If you are not familiar with generator functions, it may seem weird.

Anyway, try the working example.

https://redux-saga.js.org/docs/api/index.html#external-api

(Unfortunately, this sandbox doesn’t work online as of writing. Please “Export to ZIP” and run locally.)

useReducerAsync

use-reducer-async provides a custom hook useReducerAsync. This is the library I developed, inspired by useSagaReducer. It’s not capable of what generator functions can do, but it works with any async functions.

The following is the same example with this hook.

1import { createContainer } from 'react-tracked';
2import { useReducerAsync } from 'use-reducer-async';
3
4const initialState = { loading: false, data: null };
5const reducer = (state, action) => {
6 if (action.type === 'FETCH_STARTED') {
7 return { ...state, loading: true };
8 } else if (action.type === 'FETCH_FINISHED') {
9 return { ...state, loading: false, data: action.data };
10 } else {
11 return state;
12 }
13};
14
15const asyncActionHandlers = {
16 FETCH_DATA: (dispatch, getState) => async (action) => {
17 dispatch({ type: 'FETCH_STARTED' });
18 const response = await fetch(`https://reqres.in/api/users/${action.id}?delay=1`);
19 const data = await response.json();
20 dispatch({ type: 'FETCH_FINISHED', data });
21 },
22};
23
24const useValue = () => useReducerAsync(reducer, initialState, asyncActionHandlers);
25const { Provider, useTracked } = createContainer(useValue);

You can invoke it in the same way.

1dispatch({ type: 'FETCH_DATA', id: 1 });

The pattern is similar to useSagaReducer, but the syntax is similar to useThunkReducer or the native solution.

Try the working example.

https://codesandbox.io/s/bitter-frost-4lxck

Comparison

Although it can be biased, here’s what I suggest. If you would like a solution without libraries, use the native one. If you are saga users, use useSagaReducer with no doubt. If you like redux-thunk, useThunkReducer would be good. Otherwise, consider useReducerAsync or the native solution.

For TypeScript users, my recommendations are useSagaReducer and useReducerAsync. The native solution should also work. Please check out the fully typed examples in React Tracked.

Closing notes

To be honest, I think the native solution works fine for small apps. So, I wasn’t so motivated to create a library. However, during writing a tutorial for React Tracked, I noticed that having a pattern restricted by a library is easier to explain. use-reducer-async is a tiny library and it’s nothing fancy. But, it shows a pattern.

The other note about async actions is Suspense for Data Fetching. It’s currently in the experimental channel. The new recommended way of data fetching is Render-as-You-Fetch pattern. That’s totally different from the patterns described in this post. We will see how it goes. Most likely, that new pattern requires a library that would ease developers to follow the pattern. If you are interested, please check out my experimental project.

Read the original article or more interesting posts on Daishi’s blog.

Frontend Monitoring

Asayer is a frontend monitoring tool that replays everything your users do and shows how your web app behaves for every issue. It lets you reproduce issues, aggregate JS errors and monitor your web app’s performance.

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

More articles from Asayer Blog

Introducing Asayer Metrics

Great frontends make great digital experiences. Asayer Metrics helps you ship apps that are consistently fast, making your users delighted.

October 30th, 2020 · 2 min read

Polling in React using the useInterval Custom Hook

UseInterval is a custom Hook on React that makes intervals simpler in your web app.

October 9th, 2020 · 6 min read
© 2020 Asayer Blog
Link to $https://twitter.com/asayerioLink to $https://github.com/asayerioLink to $https://www.linkedin.com/company/18257552