React Query is a large and complete library that facilitates the work when making client-side requests to the server and even performs much more than that.
But Did you know that you can use this library as a state manager?, possibly an alternative to redux-toolkit, zustand, among others. In this article I will show you how to implement it this way.
🚨 Note: to understand this article you should have basic knowledge of how to use React Query and also some basic knowledge with TypeScript.
Table of contents.
📌 Technologies to be used.
📌 Creating the project.
📌 First steps.
📌 Creating the pages.
📌 Configuring React Query.
📌 Using React Query as status manager.📌 Creating the functions to make the requests.
📌 Getting the data with React Query.
📌 Adding new data to our state.
📌 Removing data from the state.
📌 Updating the status data.
📢 Technologies to be used.
- React JS 18.2.0
- React Query 4.20.4
- React Router Dom 6.6.1
- TypeScript 4.9.3
- Vite JS 4.0.0
- CSS vanilla (You can find the styles in the repository at the end of this post)
📢 Creating the project.
We will name the project: state-management-rq
(optional, you can name it whatever you like).
npm create vite@latest
We create the project with Vite JS and select React with TypeScript.
Then we run the following command to navigate to the directory just created.
cd state-management-rq
Then we install the dependencies.
npm install
Then we open the project in a code editor (in my case VS code).
code .
📢 First steps.
First we are going to install a dom router to be able to create a couple of pages in our app.
npm install react-router-dom
So, let's create a src/layout folder to create a very simple navigation menu that will be on all the pages.
Inside src/layout we create the index.tsx file and add the following:
import { NavLink, Outlet } from 'react-router-dom'
type LinkActive = { isActive: boolean }
const isActiveLink = ({ isActive }: LinkActive) => `link ${isActive ? 'active' : ''}`
export const Layout = () => {
return (
<>
<nav>
<NavLink className={isActiveLink} to="/">Home 🏠</NavLink>
<NavLink className={isActiveLink} to="/create">Create ✍️</NavLink>
</nav>
<hr className='divider' />
<div className='container'>
<Outlet />
</div>
</>
)
}
Then in the src/App.tsx file we are going to delete everything. And we are going to create our basic routes.
Note: We are going to set the routes using createBrowserRouter, but if you want you can use the components that react-router-dom still has like
<BrowserRouter/>
,<Routes/>
,<Route/>
, etc. instead of createBrowserRouter.
By createBrowserRouter we are going to create an object where we will add our routes. Note that I only have a parent route, and what I show is the navigation menu, and this route has 3 daughter routes, which for the moment have not been created their pages.
Finally we create the component App that we export by default, this component is going to render a component of react-router-dom that is the <RouterProvider/>
that receives the router that we have just created.
And with this we can navigate between the different routes.
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Layout } from './layout';
import { Home } from './pages/home';
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
index: true,
element: <>create</>,
},
{
path: "/create",
element: <>create</>,
},
{
path: "/:id",
element: <>edit</>,
},
]
}
]);
const App = () => ( <RouterProvider router={router} /> )
export default App
Then we will come back to this file to add more stuff 👀.
📢 Creating the pages.
Now we are going to create the three pages for the paths we defined earlier.
Create a new folder src/pages and inside create 3 files.
- home.tsx
In this file we are only going to list the data that will come from the API, so for the moment we will only put the following:
import { Link } from 'react-router-dom'
export const Home = () => {
return (
<>
<h1>Home</h1>
<div className="grid">
<Link to={`/1`} className='user'>
<span>username</span>
</Link>
</div>
</>
)
}
- createUser.tsx.
This page is only for creating new users or new data. So we will create a form. In this occasion I am not going to use a state to control the input of the form, but simply I will use the event that emits the form when it executes the onSubmit of the same one (It is important to put the attribute name to the input).
export const CreateUser = () => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.target as HTMLFormElement
const data = Object.fromEntries(new FormData(form))
// TODO: create new user
form.reset()
}
return (
<div>
<h1>Create User</h1>
<form onSubmit={handleSubmit} className='mt'>
<input name='user' type="text" placeholder='Add new user' />
<button>Add User</button>
</form>
</div>
)
}
- editUser.tsx
In this page the selected user will be edited, we will obtain his ID by means of the parameters of the URL, as we established it when we created the router.
import { useParams } from 'react-router-dom';
export const EditUser = () => {
const params = useParams()
const { id } = params
if (!id) return null
return (
<>
<span>Edit user {id}</span>
</>
)
}
Now we need to place these pages in the router!
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Layout } from './layout';
import { CreateUser } from './pages/createUser';
import { EditUser } from './pages/editUser';
import { Home } from './pages/home';
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
index: true,
element: <Home />,
},
{
path: "/create",
element: <CreateUser />,
},
{
path: "/:id",
element: <EditUser />,
},
]
}
]);
const App = () => (
<RouterProvider router={router} />
)
export default App
📢 Configuring React Query.
First we will install the library.
npm install @tanstack/react-query
Then we configure the provider in the src/App.tsx file.
- First we will create the queryClient.
For this occasion we are going to leave these options, that will help us to use React Query also as a state manager:
-
refetchOnWindowFocus
: When you exit your app and then come back React Query returns to make the request for the data. -
refetchOnMount
: When the component is remounted then it will make the request again. -
retry
: Number of times to retry the request.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
refetchOnMount: false,
retry: 1,
},
},
});
- Then we need to import the provider that offers us React Query and send it the queryClient we just created.
const App = () => (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
)
- And finally, although it is optional, but it is very very useful, we will install the React Query devtools, which will help a lot.
npm install @tanstack/react-query-devtools
Now we place the devtools inside the React Query provider.
- The file would finally look like this.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Layout } from './layout';
import { CreateUser } from './pages/createUser';
import { EditUser } from './pages/editUser';
import { Home } from './pages/home';
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
index: true,
element: <Home />,
},
{
path: "/create",
element: <CreateUser />,
},
{
path: "/:id",
element: <EditUser />,
},
]
}
]);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: 1
},
},
});
const App = () => (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
<RouterProvider router={router} />
</QueryClientProvider>
)
export default App
📢 Using React Query as status manager.
First we will create the queryFn that we will execute.
📢 Creating the functions to make the requests.
We are going to create a folder src/api, and we will create the file user.ts, here we will have the functions to make the requests, to the API.
In order not to take more time to create an API, we will use JSON place holder because it will allow us to make a "CRUD" and not only GET requests.
**We will create 4 functions to do the CRUD.
First we set the constants and the interface
The interface is as follows:
export interface User {
id: number;
name: string;
}
And the constants are:
import { User } from '../interface';
const URL_BASE = 'https://jsonplaceholder.typicode.com/users'
const headers = { 'Content-type': 'application/json' }
- First we will make a function to request the users. this function must return a promise.
export const getUsers = async (): Promise<User[]> => {
return await (await fetch(URL_BASE)).json()
}
- Then the function to create a new user, which receives a user and returns a promise that resolves the new user.
export const createUser = async (user: Omit<User, 'id'>): Promise<User> => {
const body = JSON.stringify(user)
const method = 'POST'
return await (await fetch(URL_BASE, { body, method, headers })).json()
}
- Another function to edit a user, which receives the user to edit and returns a promise that resolves the edited user.
export const editUser = async (user: User): Promise<User> => {
const body = JSON.stringify(user)
const method = 'PUT'
return await (await fetch(`${URL_BASE}/${user.id}`, { body, method, headers })).json()
}
- Finally, a function to delete the user, which receives an id. And since when deleting a record from the API, it does not return anything, then we will return a promise that resolves the id to identify which user was deleted.
export const deleteUser = async (id: number): Promise<number> => {
const method = 'DELETE'
await fetch(`${URL_BASE}/${id}`, { method })
return id
}
This is how this file would look like:
import { User } from '../interface';
const URL_BASE = 'https://jsonplaceholder.typicode.com/users'
const headers = { 'Content-type': 'application/json' }
export const getUsers = async (): Promise<User[]> => {
return await (await fetch(URL_BASE)).json()
}
export const createUser = async (user: Omit<User, 'id'>): Promise<User> => {
const body = JSON.stringify(user)
const method = 'POST'
return await (await fetch(URL_BASE, { body, method, headers })).json()
}
export const editUser = async (user: User): Promise<User> => {
const body = JSON.stringify(user)
const method = 'PUT'
return await (await fetch(`${URL_BASE}/${user.id}`, { body, method, headers })).json()
}
export const deleteUser = async (id: number): Promise<number> => {
const method = 'DELETE'
await fetch(`${URL_BASE}/${id}`, { method })
return id
}
📢 Getting the data with React Query.
Instead of placing the React Query code directly in the component, we will place them all at once in a custom hook to have our code centralized in one place.
So we will create a folder src/hook and inside a file called useUser.tsx.
The first custom hook we will create will be useGetUsers which only returns the properties returned by the useQuery hook.
Note that useQuery, needs 2 parameters, an array of strings to identify the query, and the second parameter is the function that we have done previously which is to get the users from the API.
import { useQuery } from '@tanstack/react-query';
import { getUsers } from '../api/user';
const key = 'users'
export const useGetUsers = () => {
return useQuery([key], getUsers);
}
Ahora, toca usar useGetUsers. Como notaras, es lo mismo que si usamos useQuery, pero sin necesitar establecer la queryKey y la queryFn, asiéndolo mas fácil de leer
import { Link } from 'react-router-dom'
import { useGetUsers } from '../hook/useUser'
export const Home = () => {
const { data, isLoading, isError } = useGetUsers()
return (
<>
<h1>Home</h1>
{isLoading && <span>fetching a character...</span>}
{isError && <span>Ups! it was an error 🚨</span>}
<div className="grid">
{
data?.map(user => (
<Link to={`/${user.id}`} key={user.id} className='user'>
<span>{user.name}</span>
</Link>
))
}
</div>
</>
)
}
So far, we have only set the data and stored this data in the cache (which will act as our store that stores the state), we have not yet used/modified the state of this component elsewhere.
📢 Adding new data to our state.
Let's go to src/hooks/useUser.tsx and create a new custom hook to create new users.
export const useCreateUser = () => {}
In this occasion and in the following ones, we will use useMutation because we are going to execute a POST request to create a new record.
useMutation receives the queryFn to execute, in this case we will pass it the function we created to add a new user.
export const useCreateUser = () => {
return useMutation(createUser)
}
We will pass a second parameter which will be an object, which will access the onSuccess property which is a function that is executed when the request is successful.
onSuccess receives several parameters, and we will use the first one which is the data returned by the createUser function which in this case must be the new user.
export const useCreateUser = () => {
return useMutation(createUser, {
onSuccess: (user: User) => {}
})
}
Now what we want to do is to access the cache (our state) and add this newly created user.
For this task we will use another React Query hook, useQueryClient.
🚨 Note: Do not destructure any property of the hook useQueryClient because you will lose the reference and this property will not work as you want.
Now, inside the body of the onSuccess function, let's set the new data, using the setQueryData property.
setQueryData, needs 2 parameters, the first one is the queryKey to identify which part of the cache you are going to get the data and modify it.
export const useCreateUser = () => {
const queryClient = useQueryClient();
return useMutation(createUser, {
onSuccess: (user: User) => {
queryClient.setQueryData([key])
}
})
}
The second parameter is the function to set the new data. Which must receive by parameter, the data that is already in the cache, which in this case can be an array of users or undefined.
What will be done, will be a validation, where if there are already users in the cache, we only add the new user and spread the previous users, otherwise we only return the user created in an array.
export const useCreateUser = () => {
const queryClient = useQueryClient();
return useMutation(createUser, {
onSuccess: (user: User) => {
queryClient.setQueryData([key],
(prevUsers: User[] | undefined) => prevUsers ? [user, ...prevUsers] : [user]
)
// queryClient.invalidateQueries([key])
}
})
}
🚨 Note: Observe the line commented in the previous code
queryClient.invalidateQueries([key])
This line of code is used to invalidate the cache and re-request the data from the server. This is what you normally want to do when you make some kind of POST, PUT, DELETE, etc. request..
In my case, I comment this line, because the JSON placeholder API does not modify the data, it only simulates it. So if I make a DELETE request to delete a record and everything goes well and then I put invalidateQueries, it will return all the users again and it will seem that there was no change in the data.
Once this is clear, we will use the custom hook in src/pages/createUser.tsx.
We set the custom hook, in this case, you can unstructure the props returned by this hook but I won't do it just for fun (although when we use more than one hook, this syntax will be a good option to avoid conflict with the names of the props).
const create_user = useCreateUser()
Now in the handleSubmit, we will access the mutateAsync property, and thanks to TypeScript we know what arguments we must pass, which is the name of the new user.
await create_user.mutateAsync({ name: data.user as string })
If you wonder where we get this argument, it is from the function, it is from the createUser function of the file src/>api/user.ts, it depends on what it receives as parameter, it is what we will send as argument.
And the page would look like this:
import { useCreateUser } from '../hook/useUser'
export const CreateUser = () => {
const create_user = useCreateUser()
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.target as HTMLFormElement
const data = Object.fromEntries(new FormData(form))
await create_user.mutateAsync({ name: data.user as string })
form.reset()
}
return (
<div>
<h1>Create User</h1>
<form onSubmit={handleSubmit} className='mt'>
<input name='user' type="text" placeholder='Add new user' />
{create_user.isLoading && <span>creating user...</span>}
<button>Add User</button>
{create_user.isSuccess && <span>User created successfully ✅</span>}
{create_user.isError && <span>Ups! it was an error 🚨</span>}
</form>
</div>
)
}
📢 Removing data from the state.
Now it is time to delete data, and the steps are similar to when we create data.
- We create the custom hook, useDeleteUser.
- We use useMutation, sending the function to execute, deleteUser.
- We access to the onSuccess property, to execute the function.
- We use use useQueryClient to modify the data in the cache, once the request is successful.
- We send the queryKey to the setQueryData property, and the function, we validate if there is data, if yes, we filter the data by the ID we received from the onSuccess and exclude the user we just deleted, returning the new array without the deleted user.
export const useDeleteUser = () => {
const queryClient = useQueryClient();
return useMutation(deleteUser, {
onSuccess: (id) => {
queryClient.setQueryData([key],
(prevUsers: User[] | undefined) => prevUsers ? prevUsers.filter(user => user.id !== id) : prevUsers
// queryClient.invalidateQueries([key])
)
}
});
}
We use our custom hook in src/pages/editUser.tsx.
But before we are going to separate in different components the actions to be performed. First we will create a component in the same file, we will name it DeleteUser which receives the id of the user to delete.
import { useParams } from 'react-router-dom';
import { useDeleteUser } from '../hook/useUser';
import { User } from '../interface';
export const EditUser = () => {
const params = useParams()
const { id } = params
if (!id) return null
return (
<>
<DeleteUser id={+id} />
</>
)
}
DeleteUser will have the following.
We set the custom hook useDeleteUser and access the mutateAsync method to execute the request and send it the id.
export const DeleteUser = ({ id }: Pick<User, 'id'>) => {
const delete_user = useDeleteUser()
const onDelete = async () => {
await delete_user.mutateAsync(id)
}
return (
<>
{delete_user.isLoading && <span>deleting user...</span>}
<button onClick={onDelete}>Delete User</button>
{delete_user.isSuccess && <span>User deleted successfully ✅, go back home</span>}
{delete_user.isError && <span>Ups! it was an error 🚨</span>}
</>
)
}
And that's it, once removed, go back to the Home page and you will notice that the user has been removed correctly. Of course, if you refresh the browser, this user reappears because we are using JSON placeholder.
📢 Updating the status data.
Now it is time to update a user following the same steps.
- We create the custom hook, useEditUser.
- We use useMutation, sending the function to execute, editUser.
- We access to the onSuccess property, to execute the function.
- We use use useQueryClient to modify the data in the cache, once the request is successful.
- We send the queryKey to the setQueryData property, and the function, we validate if data exists, if yes, we identify the user that was modified through the ID and we assign its new value already modified.
export const useEditUser = () => {
const queryClient = useQueryClient();
return useMutation(editUser, {
onSuccess: (user_updated: User) => {
queryClient.setQueryData([key],
(prevUsers: User[] | undefined) => {
if (prevUsers) {
prevUsers.map(user => {
if (user.id === user_updated.id) {
user.name = user_updated.name
}
return user
})
}
return prevUsers
}
)
}
})
}
Now let's go to src/pages/editUser.tsx and create 2 more components to show you a drawback.
We create the components ViewUser to see the user and EditUser which will be a form to edit the user.
import { useParams } from 'react-router-dom';
import { useDeleteUser, useEditUser, useGetUsers } from '../hook/useUser';
import { User } from '../interface';
export const EditUser = () => {
const params = useParams()
const { id } = params
if (!id) return null
return (
<>
<ViewUser id={+id} />
<EditUserForm id={+id} />
<DeleteUser id={+id} />
</>
)
}
ViewUser receives the id, and makes use of useGetUsers to fetch all users (which does not trigger another request, but accesses those in the cache).
We filter the user and display it on screen.
export const ViewUser = ({ id }: Pick<User, 'id'>) => {
const get_users = useGetUsers()
const user_selected = get_users.data?.find(user => user.id === +id)
if (!user_selected) return null
return (
<>
<h1>Edit user: {id}</h1>
<span>User name: <b>{user_selected?.name}</b></span>
</>
)
}
EditUser, it also receives an ID. In fact this component is quite the same as the one in the createUser.tsx page, you can even reuse it, but in my case I won't do it.
We use the custom hook useEditUser, we access to its method mutateAsync and we pass the necessary arguments. And ready you will be able to edit the selected user.
export const EditUserForm = ({ id }: Pick<User, 'id'>) => {
const edit_user = useEditUser()
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.target as HTMLFormElement
const data = Object.fromEntries(new FormData(form))
await edit_user.mutateAsync({ name: data.user as string, id })
form.reset()
}
return (
<>
<form onSubmit={handleSubmit}>
<input name='user' type="text" placeholder='Update this user' />
{edit_user.isLoading && <span>updating user...</span>}
<button>Update User</button>
{edit_user.isSuccess && <span>User updated successfully ✅</span>}
{edit_user.isError && <span>Ups! it was an error 🚨</span>}
</form>
</>
)
}
But be careful, you will notice that when a user is updated correctly, the ViewUser component is not rendered, that is, it keeps the value of the previous user's name. But if you go back to the Home page, you will notice that the user's name is updated.
This is because a new rendering is needed to change the ViewUser component.
For this I can think of a solution. Create a new custom hook that handles an observable and be aware of the changes in a certain part of the cache.
In this custom hook we are going to use the other custom hook useGetUsers and the hook useQueryClient.
- First we use the useGetUsers and return its props, but we overwrite the prop data, since it is the one that we have to be aware of changes.
export const useGetUsersObserver = () => {
const get_users = useGetUsers()
return {
...get_users,
data:[],
}
}
- We create a state to manage the user array, and we assign that state to the prop data.
export const useGetUsersObserver = () => {
const get_users = useGetUsers()
const [users, setUsers] = useState<User[]>()
return {
...get_users,
data: users,
}
}
- We initialize the state with the existing data in the cache, in case there is no data in the cache we return an empty array. This is achieved using the hook useQueryClient and its property getQueryData.
export const useGetUsersObserver = () => {
const get_users = useGetUsers()
const queryClient = useQueryClient()
const [users, setUsers] = useState<User[]>(() => {
const data = queryClient.getQueryData<User[]>([key])
return data ?? []
})
return {
...get_users,
data: users,
}
}
- Now we will use an effect to handle the observer. Inside we create a new instance of QueryObserver that requires two arguments, the queryClient and an object where it needs the queyKey to know which part of the cache will be watched.
useEffect(() => {
const observer = new QueryObserver<User[]>(queryClient, { queryKey: [key] })
}, [])
- Now we need to subscribe to the observer, so we execute the subscribe property of the observer. The subscribe receives a callback which returns an object that is basically the same properties that returns a hook like useQuery so we validate if in the data property there is data, then we update the state with this new data.
useEffect(() => {
const observer = new QueryObserver<User[]>(queryClient, { queryKey: [key] })
const unsubscribe = observer.subscribe(result => {
if (result.data) setUsers(result.data)
})
}, [])
- Remember that a good practice is to cancel the subscription when the component is disassembled.
useEffect(() => {
const observer = new QueryObserver<User[]>(queryClient, { queryKey: [key] })
const unsubscribe = observer.subscribe(result => {
if (result.data) setUsers(result.data)
})
return () => {
unsubscribe()
}
}, [])
And this is how this new custom hook would look like.
export const useGetUsersObserver = () => {
const get_users = useGetUsers()
const queryClient = useQueryClient()
const [users, setUsers] = useState<User[]>(() => {
const data = queryClient.getQueryData<User[]>([key])
return data ?? []
})
useEffect(() => {
const observer = new QueryObserver<User[]>(queryClient, { queryKey: [key] })
const unsubscribe = observer.subscribe(result => {
if (result.data) setUsers(result.data)
})
return () => {
unsubscribe()
}
}, [])
return {
...get_users,
data: users,
}
}
Now it is only a question of using it in the component where we want to be aware of this data. As in the ViewUser component.
Don't forget to import useGetUsersObserver.
export const ViewUser = ({ id }: Pick<User, 'id'>) => {
// const get_users = useGetUsers()
const get_users = useGetUsersObserver()
const user_selected = get_users.data?.find(user => user.id === +id)
if (!user_selected) return null
return (
<>
<h1>Edit user: {id}</h1>
<span>User name: <b>{user_selected?.name}</b></span>
</>
)
}
Now if when you try to update the data or delete it, you will see how the ViewUser component will also be updated once the request is successful.
And with this we would finish the CRUD using as state manager the React Query cache.
📢 Conclusion.
React Query is a very powerful library that certainly helps us with request handling. But now you can extend it much more knowing that you can use it as a status manager, probably one more alternative.
I hope you liked this post and I also hope I helped you to extend your knowledge with React Query.
If you know any other different or better way on how to manage status using React Query, feel free to let me know in the comments.
I invite you to check my portfolio in case you are interested in contacting me for a project!. Franklin Martinez Lucas
🔵 Don't forget to follow me also on twitter: @Frankomtz361
📢 Demo.
https://rq-state-management.netlify.app/
Top comments (3)
I really didn't know you could do that with react query. 😵💫
Really nice article exploring the advanced use of this library. Using this library brings many advantages such as performance, but it brings a lot of complexity, expensive configurations and also, large coupling to the lib.
This is cool. React Query is a server state manager that can be used for UI state too.
My company just open-sourced a tool that takes the opposite approach - it's a UI state manager that can be used for server state too. Not a full replacement for React Query, but it would handle the specific examples in this article much more cleanly.
May be worth checking out if you're interested in this sort of hybrid tooling. GitHub Repo here