Tanstack Query as a synchronous state manager
Should you do it, or should you not?
Background and Motivation
Tanstack Query is a powerful asynchronous state management tool for TypeScript and JavaScript projects. Although it is called ‘asynchronous state management,’ we can also utilize Tanstack Query to handle our synchronous state management, especially for global state management.
Personally, I usually use TanStack Query to handle API calls, so I do not manually manage refetching, caching, error handling, loading, etc., because TanStack Query handles these by default. However, I recently discovered an article by the TanStack Query maintainer that mentioned using React Query as a state manager. I wonder if I can use it to manage business logic state, similar to how we use useState
, useReducer
, Redux
, Zustand
, etc.
Then I decided to give it a try, and here are my trial results:
Synchronous State Management
For this example, we will try incrementing and decrementing state manipulation. Usually, we use useState, but now let’s try to implement increment and decrement functionality using TanStack Query. You can set it up like this, and it will work as expected. However, the numerical value will be stored in the TanStack Query cache management, allowing this number query key to be used anywhere in the app.
// main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const client = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={client}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
// App.tsx
import { useQuery, useQueryClient } from "@tanstack/react-query";
import "./App.css";
interface NumberData {
number: number;
}
const Action = () => {
const client = useQueryClient();
return (
<>
<button
onClick={() => {
client.setQueryData<NumberData>(["number"], (prev) => {
return {
number: (prev?.number || 0) - 1,
};
});
}}
>
decrement
</button>
<button
onClick={() => {
client.setQueryData<NumberData>(["number"], (prev) => {
return {
number: (prev?.number || 0) + 1,
};
});
}}
>
increment
</button>
</>
);
};
const Number = () => {
const { data } = useQuery<NumberData>({
queryKey: ["number"],
initialData: {
number: 0,
},
});
return <p>{data?.number}</p>;
};
function App() {
return (
<>
<Action />
<Number />
</>
);
}
export default App;
Synchronous State Management (with reducer)
Okay, and what if we face more complex business logic and need to utilize a cleaner approach similar to useReducer or Redux? We can create a reducer function to handle this, providing a useReducer and Redux-like experience but with less boilerplate.
// StateManager/index.tsx
import { useQuery, useQueryClient } from "@tanstack/react-query";
interface NumberData {
number: number;
}
type Action = { type: "increment" } | { type: "decrement" };
function reducer(state: NumberData, action: Action): NumberData {
switch (action.type) {
case "increment":
return { number: state.number + 1 };
case "decrement":
return { number: state.number - 1 };
default:
throw new Error();
}
}
const useNumber = () => {
const { data } = useQuery<NumberData>({
queryKey: ["number"],
});
return data;
};
const useNumberDispatch = () => {
const client = useQueryClient();
const dispatch = (action: Action) => {
client.setQueryData(["number"], (oldState: NumberData) => {
return reducer(oldState, action);
});
};
return dispatch;
};
export { useNumber, useNumberDispatch };
// App.tsx
import "./App.css";
import { useNumber, useNumberDispatch } from "./StateManager";
const Action = () => {
const numberDispatch = useNumberDispatch();
return (
<>
<button
onClick={() => {
numberDispatch({
type: "decrement",
});
}}
>
decreament
</button>
<button
onClick={() => {
numberDispatch({
type: "increment",
});
}}
>
increament
</button>
</>
);
};
const Number = () => {
const data = useNumber();
return <p>{data?.number}</p>;
};
function App() {
return (
<>
<Action />
<Number />
</>
);
}
export default App;
Summary
In summary, yes, it actually works, and the rendering cycle is also optimized without any unnecessary re-renders. However, personally, I am still not sure if we can use this approach for large-scale business logic features. There are other opinions about this approach as well: