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:
- Set up some state with
useState
to hold the result of an async request - Create an effect with
useEffect
that makes a request to your API and saves the result to your state - 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!