How to manage server and client state in a web application
We are currently working on migrating our platform from AngularJS to React, it's a Single Page Application used for managing access at scale. One common component is how to manage our server side state in the web client. In this article we talk about state in a react application and specially the differences between client-side and server-side state. These are two different kinds of state that usually get mixed together in redux or some similar state management library.
Background
One common case with React apps is to keep track of the client-side state of our UI components. We can change a particular state to show the menu as open or closed. This state is easy to manage because we have full control over it. What about server side state and data from our database? In React there are many different ways to accomplish this. Before React 16 and hooks we usually made requests to our backends in our lifecycle method componentDidMount() and then set the data to our state in that component. Or even better, we created a component with function as child to which we could pass a URL and maybe some query-parameters and we got our data back. It might look something like this:
<DataComponent url={'/posts'} params={{ archived: true }}>
{(data, isLoading) => isLoading
? <Loading />
: <ListOfPosts posts={data} />
}
</DataComponent>
What if we want to show our data from the backend in multiple places and we don't want to refetch the same data twice. Well, most of us looked at redux to solve this problem. It's a state management library so why not put our data in there together with some isPending, isError and isSuccess state so that we can keep track of our progress when fetching data.
Redux can only dispatch pure actions so how could we create a redux action which fires off a request? Redux-thunks to the rescue! With thunks we can make action creators that handle this.
export function fetchSomething(url) {
return (dispatch) => {
dispatch({ type: 'PENDING' });
return axios.get(url)
.then((response) => {
dispatch({ type: 'SUCCESS', payload: response.data });
})
.catch((error) => {
dispatch({ type: 'ERROR', payload: error });
});
}
}
We could of course extend this and add different prefixes to our types in order to separate different requests. We also used middleware to handle side-effects for specific actions.
This solved a lot of problems for developers using React but at the same time it creates a lot of other problems like cache invalidation and keeping our state in sync with our backend.
There are only two hard things in Computer Science: cache invalidation, naming things, and off-by-one errors.
Why is this a problem?
Let us boil down the problem. What does our state look like when we use redux? Basically it is just an object with a key that we can use to look up the state of our data associated with that key.
It might look something like this:
{
"hardware": {
entities: [ { id: '1234', ... }, { ... },
frontPage: {
ids: [ '1234', '5678', ... ],
status: 'PENDING' | 'SUCCESS' | 'ERROR' | undefined
}
},
...
}
Remember, this is NOT client state. This is a client-side cache of the server side data which we have no control over. Some other users might change some data meaning our data is outdated. This can lead to a bad user experience and we don't want that. It also adds a lot of boilerplate code for each endpoint that we want to get data from.
We want our users to always look at the latest data so that they can make the correct decisions all the time. We want to invalidate caches so it refetch new data in the background.
We started to look around for potential solutions to this problem and along comes a guy named Tanner Linsley. He is an open source contributor and the author of many great React libraries including react-tables, react-charts and his latest creation react-query. He stated something that I think a lot of people have thought about but haven't had a good solution for. Which is the fact that we have two different kinds of state in our application, client and server side state.
How we solved server state with React query
Since React hooks came we feel that there hasn't been a great solution for fetching data until we found react-query. It is built to keep your server side state up to date at all times and it uses hooks. At a glance, react-query associates a QueryKey to a specific query. The cool thing about it is that the QueryKey could be any serializable value so it could be as simple as a string or an array of strings/objects. Which means that you could create a QueryKey that looks like this
['hardware', { page: 1, limit: 25, q: "my search word" }]
which associates the fetched data to a specific key. You can invalidate all queries associated with 'hardware' and it will get refetched from our APIs automatically.
With react-query we could remove a lot of boilerplate code that is required for redux-thunk actions. We still use redux but only for client-side state like theme, language and shared UI-states. The UI-state gets much easier to reason about since it's not coupled with external dependencies.
React query consists of two basic hooks, useMutation and useQuery where we used the first for Post, Put or Delete requests and the second one for Get requests.
We have created a few different base mutation patterns for Create, Update and Delete actions. This is an example of our createMutation hook. It accepts a QueryKey and a url and returns a useMutation hook. The useMutation hook accepts a second configuration object. When our mutation succeeds we call our clearCache function with the queryCache object and our cacheKey. By doing this we can control our cache 100%.
export default function useCreateMutation(cacheKey, url) {
const queryCache = useQueryCache();
return useMutation(cacheKey,
(data) => axios.post(url, data),
{
onSettled: () => clearCache(queryCache, cacheKey)
});
}
If a mutation occurs for our cacheKey 'Hardware' we want to invalidate our queries that have the cache keys 'Permissions', 'Domain' and of course 'Hardware'.
export default function clearCache(queryCache, cacheKey) {
queryCache.invalidateQuery(cacheKey);
switch (cacheKey) {
case 'Hardware':
queryCache.invalidateQuery('Permissions');
queryCache.invalidateQuery('Domain');
break;
// other cases...
}
}
You can then use the mutation hook in a component like this
function Component1() {
const [createHardware, mutateInfo] = useCreateMutation('hardware', '/hardware')
const submit = () => {
createHardware(data);
};
return <button onClick={submit}>Save my data</button>;
}
Now in our other component that uses a useQuery hook to fetch some domain data we don’t have to worry about it showing stale or outdated data. If <Component1 /> updates the data and we invalidate our Domain queries in the onSettled callback this data will get re-fetched in the background when that mutation either succeeds or fails.
We have chosen a more aggressive caching disability strategy and invalidate it on both successful and failed requests. But it's really up to what kind of system you are building. We think it's better to start with an aggressive approach and then optimize specific mutations or queries if necessary.
function ListOfDomains({ params }) {
const domains = useQuery(
['Domain', params],
() => axios.get('/domains', query)
);
if (domains.isFetching) {
return 'Loading ...';
}
if (domains.isError) {
return <ErrorComponent error={domains.error} />;
}
return (
<List>
{domains.data.map(domain =>
<ListItem domain={domain} key={domain.id} />)
}
</List>
);
}
The best part is that we could wrap our useQuery hook above in a useFindDomains hook that we can use anywhere in our application. Since they will share the same cacheKey, the data will only get fetched once.
Out of the box, react-query will refetch all stale data when the users come back to our site. For example if the user switches to another tab in the browser and then comes back to our tab 15 minutes later all hooks that are mounted will be refetched from our server. You can change this behavior in the react-query configuration.
By changing to react-query we have accomplished at least 2 valuable things. For one, we have simplified our code for fetching data and keeping that data in sync with our backend. Second, we have reduced our global redux state and made that easier to work with. We also have full control over the state that we store in redux.
If you are working on a web application talking to a server API, I strongly recommend to look at the react-query documentation. They also have a Discord server where you can get help. I'm online at the discord server most of the time to help others and get inspiration for myself to see how others have solved similar problems.
Happy hacking!
Are you interested in React and want to use the latest technology? Join us!