Reusing logic in React has been complex, and patterns like HOCs and Render Props tried to solve that problem. With the recent addition of Hooks, reusing logic becomes easier. In this article, I will show a simple way to use the Hooks useEffect
and useState
to load data from a web service (I'm using swapi.co in the examples to load Star Wars starships) and how to manage the loading state easily. As a bonus, I'm using Typescript. I will build a simple app to buy and sell Star Wars starships, you can see the final result here https://camilosw.github.io/react-hooks-services
Loading initial data
Until the release of React Hooks, the simplest way to load initial data from a web service was on the componentDidMount
:
class Starships extends React.Component {
state = {
starships: [],
loading: true,
error: false
}
componentDidMount () {
fetch('https://swapi.co/api/starships')
.then(response => response.json())
.then(response => this.setState({
starships: response.results,
loading: false
}))
.catch(error => this.setState({
loading: false,
error: true
}));
}
render () {
const { starships, loading, error } = this.state;
return (
<div>
{loading && <div>Loading...</div>}
{!loading && !error &&
starships.map(starship => (
<div key={starship.name}>
{starship.name}
</div>
))
}
{error && <div>Error message</div>}
</div>
);
}
};
But reusing that code is hard because you can't extract behavior from the component prior React 16.8. The popular choices are to use higher-order components or render props, but there are some downsides with those approaches as described on the React Hooks documentation https://reactjs.org/docs/hooks-intro.html#its-hard-to-reuse-stateful-logic-between-components
With Hooks, we can extract the behavior to a custom Hook so we can reuse it in any component easily. If you don't know how to create custom Hooks, read the docs first: https://reactjs.org/docs/hooks-custom.html.
Because we are using Typescript, first we need to define the shape of the data we expect to receive from the web service, so I defined the interface Starship
:
export interface Starship {
name: string;
crew: string;
passengers: string;
cost_in_credits?: string;
url: string;
}
And because we will be dealing with web services that have multiple states, I defined one interface per state. Finally, I defined Service
as a union type of those interfaces:
interface ServiceInit {
status: 'init';
}
interface ServiceLoading {
status: 'loading';
}
interface ServiceLoaded<T> {
status: 'loaded';
payload: T;
}
interface ServiceError {
status: 'error';
error: Error;
}
export type Service<T> =
| ServiceInit
| ServiceLoading
| ServiceLoaded<T>
| ServiceError;
ServiceInit
and ServiceLoading
define the state of the web service before any action and while loading respectively. ServiceLoaded
has the property payload
to store the data loaded from the web service (note that I'm using a generic here, so I can use that interface with any data type for the payload). ServiceError
has the property error
to store any error that may occur. With this union type, if we set the string 'loading'
in the status
property and try to assign something to payload
or error
properties, Typescript will fail, because we didn't define an interface that allows a status
of type 'loading'
alongside a property named payload
or error
. Without Typescript or any other type checking, your code will only fail at runtime if you make that mistake.
With the type Service
and the interface Starship
defined, now we can create the custom Hook usePostStarshipService
:
import { useEffect, useState } from 'react';
import { Service } from '../types/Service';
import { Starship } from '../types/Starship';
export interface Starships {
results: Starship[];
}
const usePostStarshipService = () => {
const [result, setResult] = useState<Service<Starships>>({
status: 'loading'
});
useEffect(() => {
fetch('https://swapi.co/api/starships')
.then(response => response.json())
.then(response => setResult({ status: 'loaded', payload: response }))
.catch(error => setResult({ status: 'error', error }));
}, []);
return result;
};
export default usePostStarshipService;
This is what happens in the previous code:
- Because SWAPI returns an array of starships inside the array
results
, I defined a new interfaceStarships
that contains the propertyresults
as an array ofStarship
. - The custom Hook
usePostStarshipService
is simply a function, starting with the worduse
as recommended on the React Hooks documentation: https://reactjs.org/docs/hooks-custom.html#extracting-a-custom-hook. - Inside that function, I'm using the Hook
useState
to manage the web service states. Note that I need to define the exact type of data that will be managed by theresult
state passing the generic<Service<Starship>>
. I'm initializing the Hook with the interfaceServiceInit
of the union typeService
, so the only property allowed isstatus
with the string'loading'
. - I'm also using the Hook
useEffect
with a callback as the first argument to fetch the data from the web service, and an empty array as the second argument. That second argument tellsuseEffect
what is the condition to run the callback, and because we are passing an empty array, the callback will be called only once (read more aboutuseEffect
if you are not familiarized with that Hook https://reactjs.org/docs/hooks-effect.html). - Finally, I'm returning
result
. That object contains the state and any payload or error as a result of calling the web service. That's what we need in our component to show the status of the web service to the user and the data retrieved.
Note that the way I used fetch
in the previous example is very simple but not enough for production code. For example, the catch will only capture network errors, not 4xx or 5xx errors. In your own code, it's better to create another function that wraps fetch
for handling errors, headers, etc.
Now, we can use our Hook to retrieve the starships list and show them to the user:
import React from 'react';
import useStarshipsService from '../services/useStarshipsService';
const Starships: React.FC<{}> = () => {
const service = useStarshipsService();
return (
<div>
{service.status === 'loading' && <div>Loading...</div>}
{service.status === 'loaded' &&
service.payload.results.map(starship => (
<div key={starship.url}>{starship.name}</div>
))}
{service.status === 'error' && (
<div>Error, the backend moved to the dark side.</div>
)}
</div>
);
};
export default Starships;
This time, our custom Hook useStarshipService
will manage the status, so we only need to render conditionally based on the status
property of the returned service
object.
Note that if you try to access payload
when status is 'loading'
, TypeScript will fail, because payload
only exists in the ServiceLoaded
interface, not in the ServiceLoading
one:
TypeScript is enough smart to know that if the comparison between the status
property and the string 'loading'
is true, the corresponding interface is ServiceLoaded
and in that circumstance the starships
object doesn't have a payload
property.
Loading content on state change
In our example, if the user clicks on any starship, we change the state on our component to set the selected starship and call the web service with the url corresponding to that ship (note that https://swapi.co/api/starships loads all the data of every starship, so there is no need to load that data again. I'm doing that only for demonstration purposes.)
Traditionally we used componentDidUpdate to detect state changes and do something in consequence:
class Starship extends React.Component {
...
componentDidUpdate(prevProps) {
if (prevProps.starship.url !== this.props.starship.url) {
fetch(this.props.starship.url)
.then(response => response.json())
.then(response => this.setState({
starship: response,
loading: false
}))
.catch(error => this.setState({
loading: false,
error: true
}));
}
}
...
};
If we need to make different actions when different props and state properties change, componentDidUpdate
quickly becomes a mess. With Hooks we can encapsulate that actions in separated custom Hooks. In this case, we'll create a custom Hook to extract the behavior inside componentDidUpdate
as we did previously:
import { useEffect, useState } from 'react';
import { Service } from '../types/Service';
import { Starship } from '../types/Starship';
const useStarshipByUrlService = (url: string) => {
const [result, setResult] = useState<Service<Starship>>({
status: 'loading'
});
useEffect(() => {
if (url) {
setResult({ status: 'loading' });
fetch(url)
.then(response => response.json())
.then(response => setResult({ status: 'loaded', payload: response }))
.catch(error => setResult({ status: 'error', error }));
}
}, [url]);
return result;
};
export default useStarshipByUrlService;
This time, our custom Hook receives the url as a parameter, and we use that as the second argument of the Hook useEffect
. That way, every time the url change, the callback inside useEffect
will be called retrieving the data for the new starship.
Note that inside the callback, I'm calling setResult
to set status
as 'loading'
. That's because the callback will be called multiple times, so we need to reset the status before start fetching.
In our Starship
component, we receive the url as a prop and pass it to our custom Hook useStarshipByUrlService
. Every time the url change in the parent component, our custom Hook will call again the web service and will manage the status for us:
import React from 'react';
import useStarshipByUrlService from '../services/useStarshipByUrlService';
export interface Props {
url: string;
}
const Starship: React.FC<Props> = ({ url }) => {
const service = useStarshipByUrlService(url);
return (
<div>
{service.status === 'loading' && <div>Loading...</div>}
{service.status === 'loaded' && (
<div>
<h2>{service.payload.name}</h2>
...
</div>
)}
{service.status === 'error' && <div>Error message</div>}
</div>
);
};
export default Starship;
Sending content
Sending content seems similar to loading content when state change. In the first case we passed a url to our custom Hook and now we could pass an object with the data to be sent. If we try to do the same, the code will be something like this:
const usePostStarshipService = (starship: Starship) => {
const [result, setResult] = useState<Service<Starship>>({
status: 'init'
});
useEffect(() => {
setResult({ status: 'loading' });
fetch('https://swapi.co/api/starships', {
method: 'POST',
body: JSON.stringify(starship)
})
.then(response => response.json())
.then(response => {
setResult({ status: 'loaded', payload: response });
})
.catch(error => {
setResult({ status: 'error', error });
});
}, [starship]);
return result;
};
const CreateStarship: React.FC<{}> = () => {
const initialStarshipState: Starship = {
name: '',
crew: '',
passengers: '',
cost_in_credits: ''
};
const [starship, setStarship] = useState<PostStarship>(initialStarshipState);
const [submit, setSubmit] = useState(false);
const service = usePostStarshipService(starship);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.persist();
setStarship(prevStarship => ({
...prevStarship,
[event.target.name]: event.target.value
}));
};
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmit(true);
};
useEffect(() => {
if (submit && service.status === 'loaded') {
setSubmit(false);
setStarship(initialStarshipState);
}
}, [submit]);
return (
<form onSubmit={handleFormSubmit}>
<input
type="text"
name="name"
value={starship.name}
onChange={handleChange}
/>
...
</form>
)
}
But there are some problems on the previous code:
- We passed the
starship
object to our custom Hook and we passed that object as the second argument of theuseEffect
Hook. Because the onChange handler will change thestarship
object on every keystroke, our web service will be called every time the user type. - We need to use the Hook
useState
to create the boolean statesubmit
only to know when we can clean the form. We could use this boolean as the second parameter ofusePostStarshipService
to solve the previous problem, but we'd be complicating our code. - The boolean state
submit
added logic to our component that must be replicated on other components that reuse our custom HookusePostStarshipService
.
There is a better way, this time without the useEffect
Hook:
import { useState } from 'react';
import { Service } from '../types/Service';
import { Starship } from '../types/Starship';
export type PostStarship = Pick<
Starship,
'name' | 'crew' | 'passengers' | 'cost_in_credits'
>;
const usePostStarshipService = () => {
const [service, setService] = useState<Service<PostStarship>>({
status: 'init'
});
const publishStarship = (starship: PostStarship) => {
setService({ status: 'loading' });
const headers = new Headers();
headers.append('Content-Type', 'application/json; charset=utf-8');
return new Promise((resolve, reject) => {
fetch('https://swapi.co/api/starships', {
method: 'POST',
body: JSON.stringify(starship),
headers
})
.then(response => response.json())
.then(response => {
setService({ status: 'loaded', payload: response });
resolve(response);
})
.catch(error => {
setService({ status: 'error', error });
reject(error);
});
});
};
return {
service,
publishStarship
};
};
export default usePostStarshipService;
First, we created a new PostStarship
type derived from Starship
, picking the properties that will be sent to the web service. Inside our custom Hook, we initialized the service with the string 'init'
in the property status
because usePostStarshipService
will do nothing with the web service when called. Instead of the useEffect
Hook, this time we created a function that will receive the form data to be sent to the web service and will return a Promise. Finally, we return an object with the service
object and the function in charge to call the web service.
Note: I could have returned an array instead of an object in our custom Hook to behave like the useState
Hook, that way the names in the component could be named arbitrarily. I decided to return an object instead because I think there is no need to rename them. You are free to return an array instead if you prefer.
Our CreateStarship
component will be simpler this time:
import React, { useState } from 'react';
import usePostStarshipService, {
PostStarship
} from '../services/usePostStarshipService';
import Loader from './Loader';
const CreateStarship: React.FC<{}> = () => {
const initialStarshipState: PostStarship = {
name: '',
crew: '',
passengers: '',
cost_in_credits: ''
};
const [starship, setStarship] = useState<PostStarship>(
initialStarshipState
);
const { service, publishStarship } = usePostStarshipService();
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.persist();
setStarship(prevStarship => ({
...prevStarship,
[event.target.name]: event.target.value
}));
};
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
publishStarship(starship).then(() => setStarship(initialStarshipState));
};
return (
<div>
<form onSubmit={handleFormSubmit}>
<input
type="text"
name="name"
value={starship.name}
onChange={handleChange}
/>
...
</form>
{service.status === 'loading' && <div>Sending...</div>}
{service.status === 'loaded' && <div>Starship submitted</div>}
{service.status === 'error' && <div>Error message</div>}
</div>
);
};
export default CreateStarship;
I'm using the useState
Hook to manage the state of the form but handleChange
behaves as when we use this.state
in class components. Our usePostStarshipService
does nothing other than returning our service
object in an initial state and returning the publishStarship method to call the web service. When the form is submitted and handleFormSubmit
is called, we call publishStarship
with the form data. Now our service
object starts to manage the state of the web service changes. If the returned promise is success, we call setStarship
with the initialStarshipState
to clean the form.
And that's all, we have three custom Hooks to retrieve initial data, retrieve individual items and post data. You can see the full project here: https://github.com/camilosw/react-hooks-services
Final Thoughts
React Hooks are a great addition but don't try to overuse them when there are simpler and well-established solutions, like the Promise instead of useEffect
on our sending content example.
There is another benefit when using Hooks. If you look closer, you will see that our components became basically presentational, because we moved the stateful logic to our custom Hooks. There is an established pattern to separate logic from presentation, called container/presentational, where you put the logic in a parent component and presentation in children components. That pattern was initially ideated by Dan Abramov, but now that we have Hooks, Dan Abramov advises to use less that pattern in favor of using Hooks: https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0
Probably you hate using strings to name states and blamed me for doing that, but if you are using Typescript, you are safe, because Typescript will fail if you misspell the state name and you will get autocomplete for free in VS Code (and other editors probably). Anyway, you can use booleans if you prefer.
Top comments (6)
"Inside that function, I'm using the Hook useState to manage the web service states. Note that I need to define the exact type of data that will be managed by the result state passing the generic Starships not Starship. I'm initializing the Hook with the interface ServiceInit of the union type Service, so the only property allowed is status with the string 'loading'."
You initialized with ServiceLoading not ServiceInit and the generic Starships not Starship. I am following your tutorial to solve a challenge so I am following the tutorial closely.
Nice work
Thank you for this, this really saved me. Very well written, and link too a GitHub repo also makes this worth vile.
Thank you, Camilo.
It was a fun & educational article as you provided best practices as well.
Beware of this pattern, once Suspense for data fetching is out (soon) this pattern will be obsolete.
You're right, but if you need to start a project just now and can't wait until suspense for data fetching is out, this is still valid.