Async Effects with Hooks

Tl;DR: just give me the code

If you’ve been using React hooks then you’ve almost certainly written something like this:

const MyComponent: FC = () => {
  const [things, setThings] = useState(null);

  useEffect(() => {
    async function loadTheThings() {
      const result = await myApi.getTheThings();
      setThings(result);
    }

    loadTheThings();
  }, []);

  return <Things>{things}</Things>;
};

It’s a fairly common pattern:

  1. Set up some state with useState to hold the result of an async request
  2. Create an effect with useEffect that makes a request to your API and saves the result to your state
  3. Render the results

Simple enough, but there are a few things missing here. You probably care about loading status so we need to add some kind of isLoading flag…

const MyComponent: FC = () => {
  const [things, setThings] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    async function loadTheThings() {
      setIsLoading(true);
      const result = await myApi.getTheThings();
      setThings(result);
      setIsLoading(false);
    }

    loadTheThings();
  }, []);

  if (isLoading) return <Spinner />;

  return <Things>{things}</Things>;
};

Now you can show a lovely spinner whilst your API chugs through the request. But what if the API fails? You should probably catch any errors and show a message to the user…

const MyComponent: FC = () => {
  const [things, setThings] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function loadTheThings() {
      setIsLoading(true);
      setError(null);
      try {
        const result = await myApi.getTheThings();
        setThings(result);
      } catch (e) {
        setError(e);
      }
      setIsLoading(false);
    }

    loadTheThings();
  }, []);

  if (isLoading) return <Spinner />;

  if (error) return <ErrorMessage>{error}</ErrorMessage>;

  return <Things>{things}</Things>;
};

Looking good! We do have a bunch of dependent state now though (i.e. state properties that will always change together), so we really ought to switch to useReducer instead of useState

interface State {
  isLoading: boolean;
  result?: MyThing;
  error?: any;
}

interface LoadStarted {
  type: 'STARTED';
}
interface LoadComplete {
  type: 'COMPLETE';
  result: MyThing;
}
interface LoadFailed {
  type: 'FAILED';
  error: any;
}
type LoadAction = LoadStarted | LoadComplete | LoadFailed;

const reducer = (state: State, action: LoadAction) => {
  switch (action.type) {
    case 'STARTED':
      return { ...state, isLoading: true, error: undefined };
    case 'COMPLETE':
      return { ...state, isLoading: false, result: action.result };
    case 'FAILED':
      return { ...state, isLoading: false, error: action.error };
  }
};

const MyComponent: FC = () => {
  const [state, dispatch] = useReducer(reducer, { isLoading: false });

  useEffect(() => {
    async function loadTheThings() {
      dispatch({ type: 'STARTED' });
      try {
        const result = await myApi.getTheThings();
        dispatch({ type: 'COMPLETE', result });
      } catch (error) {
        dispatch({ type: 'FAILED', error });
      }
    }

    loadTheThings();
  }, []);

  if (state.isLoading) return <Spinner />;

  if (state.error) return <ErrorMessage>{state.error}</ErrorMessage>;

  return <Things>{state.things}</Things>;
};

Phew! We’ve created a load of code there but it’s all working nicely. Thing is…we only really care about this bit of the code:

const MyComponent: FC = () => {
  const state = getStateSomehow(myApi.getTheThings);

  // render
};

So can we take all of that boilerplate and wrap it up somewhere reusable?

The Custom Hook

Of course we can! Custom hooks allow us to create our own hooks for reusable functionality and this looks like a great candidate.

We can create a generic useAsyncEffect method that exposes only the interesting parts and hides away the boilerplate.

type AsyncFunction<TResult> = () => Promise<TResult>;
function useAsyncEffect<TResult>(action: AsyncFunction<TResult>) {
  //...
}

Next we can grab our reducer from the previous version and drop this in. We’ll need to work a little harder to make it work with that generic TResult return type but nothing too painful.

interface LoadStarted {
  type: 'STARTED';
}
interface LoadComplete<TResult> {
  type: 'COMPLETE';
  result: TResult;
}
interface LoadFailed {
  type: 'FAILED';
  error: any;
}
type LoadAction<TResult> = LoadStarted | LoadComplete<TResult> | LoadFailed;
type AsyncFunction<TResult> = () => Promise<TResult>;

interface State<TResult> {
  isLoading: boolean;
  result?: TResult;
  error?: any;
}

function reducer<TResult>(state: State<TResult>, action: LoadAction<TResult>) {
  switch (action.type) {
    case 'STARTED':
      return { ...state, isLoading: true, error: undefined };
    case 'COMPLETE':
      return { ...state, isLoading: false, result: action.result };
    case 'FAILED':
      return { ...state, isLoading: false, error: action.error };
  }
}

function useAsyncEffect<TResult>(action: AsyncFunction<TResult>) {
  type ReducerType = Reducer<State<TResult>, LoadAction<TResult>>;
  const [state, dispatch] = useReducer<ReducerType>(reducer, {
    isLoading: false
  });

  //...
}

We’re having to explicitly specify the type of the reducer in useReducer to help out typescript with the inference but otherwise very little has changed.

Finally we need to drop in our useEffect implementation and return the state as a result.

function useAsyncEffect<TResult>(action: AsyncFunction<TResult>) {
  type ReducerType = Reducer<State<TResult>, LoadAction<TResult>>;
  const [state, dispatch] = useReducer<ReducerType>(reducer, {
    isLoading: false
  });

  useEffect(() => {
    async function loadTheThings() {
      dispatch({ type: 'STARTED' });
      try {
        const result = await action();
        dispatch({ type: 'COMPLETE', result });
      } catch (error) {
        dispatch({ type: 'FAILED', error });
      }
    }

    loadTheThings();
  }, []);

  return state;
}

Now we can use it in a component without all the noise:

const MyComponent: FC = () => {
  const { result, isLoading, error } = useAsyncEffect(myApi.getTheThings);

  //render away!
};

Effect Dependencies

The above approach is great if you always load the same resource, but if you want to load something different when a prop changes? Imagine the below

type Props = { location: string };
const WeatherIcon: FC<Props> = ({ location }) => {
  const { result } = useAsyncEffect(() => weatherApi.getLocation(location));

  //...render...
};

In this case we need to tell React to re-evaluate our effect when location changes. We’re currently passing an empty array into useEffect as the second parameter which effectively says “never re-run this effect”.

We can support changing dependencies by accepting a dependency array into our hook and passing it through to useEffect:

function useAsyncEffect<TResult>(
  action: AsyncFunction<TResult>,
  dependencies: any[]
) {
  // as above

  useEffect(() => {
    // as above
  }, dependencies);

  return state;
}

Now we can instruct React to re-evaluate the async condition whenever location changes

type Props = { location: string };
const WeatherIcon: FC<Props> = ({ location }) => {
  const { result } = useAsyncEffect(() => weatherApi.getLocation(location), [
    location
  ]);

  // render
};

Full Version

You can grab the final version of this code here.

Enjoy!