Using React Query to Sync with the Server
In most frontend applications, React Query acts as a bridge between the server and the frontend. With it, you can not only read data but also mutate server data. The more we use React Query, the more we should think about abstraction for the simplification and encapsulation of code.
A few years ago, with Redux, we stored most of the business logic for fetching data at the reducer level, which provided a good abstraction layer where we could separate application business logic from components. However, with React Query, components started to grow with logic about how and when to fetch data, what to do when updating server state, and so on. This has reintroduced the problem of encapsulation and simplification.
This article describes methods for returning your components to a clearer state and how to manage fetching logic, specifically discussing how we can use the "Common Query Separation" and "Separation of Concerns" patterns with React.
Custom Hooks as a New Layer for Abstracting Logic
Custom hooks were introduced in React as a solution for abstracting logic from functional components. They allow you to move part of the state management or business logic into reusable hooks. However, without a clear purpose, creating many custom hooks can complicate the project. On the other hand, the wise use of custom hooks can bring significant benefits to your codebase.
In React, the difference between a custom hook and a regular function lies in the use of React hooks like useState
, useEffect
, etc. Simply put, custom hooks leverage React's lifecycle methods, whereas regular functions do not.
export const useFetchUsers = () => {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Function to fetch users from the API
const fetchUsers = async () => {
try {
const response = await axios.get('https://jsonplaceholder.typicode.com/users');
setUsers(response.data);
setIsLoading(false);
} catch (err) {
setError(err);
setIsLoading(false);
}
};
fetchUsers();
}, []);
return { users, isLoading, error };
};
This is why we can start treating custom hooks as functions and think about how to abstract logic into them, just as we do with classes or regular functions.
Why to store React Query in custom hooks and Separation of Concerns
React Query hooks provide extensive functionality and can replace the example below with just a few lines of code:
export const useUsersQuery = () => {
return useQuery(
queryKey: usersQueryKeys.list(),
queryFn: fetchUsers
);
};
If you're interested in creating queries and making them more reusable, you can find more details in my article Mastering React Query. Structuring Your Code for Scalability and Reusability
In reality, working with React Query often requires additional logic after fetching data. For example:
- Nested queries: where one query depends on another.
- Data transformation.
- Validation of data from the server.
- Refetching data when something changes (like filter changes).
Of course, you could store all this logic within the component where you call the React Query hook, but this approach often leads to components with a lot of complex logic, making them difficult to follow and reducing reusability.
Let's see an example of a component using React Query without custom hooks:
const UsersComponent = () => {
const { data: users, error, isLoading } = useQuery(['users'], fetchUsers, {
staleTime: 30000, // Cache data for 30 seconds
refetchOnWindowFocus: false, // Disable refetch on window focus
});
// Data transformation
const activeUsers = users?.filter(user => user.isActive);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading users: {error.message}</p>;
return (
<ul>
{activeUsers.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
};
At first glance, this code looks fine, but in a complex project, the logic in this component can grow quickly, making it harder to follow and reducing reusability.
Now, let's look at this example with custom hook abstraction:
const useFetchActiveUsers = () => {
const query = useQuery(['users'], fetchUsers, {
staleTime: 30000, // Cache data for 30 seconds
refetchOnWindowFocus: false, // Disable refetch on window focus
});
// Additional logic: filtering active users
const activeUsers = query.data?.filter(user => user.isActive);
return { ...query, data: activeUsers };
};
const UsersComponent = () => {
const { data: activeUsers, error, isLoading } = useActiveUsersQuery();
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading users: {error.message}</p>;
return (
<ul>
{activeUsers.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
};
We applied the "Separation of Concerns" or "Single Responsibility Principle" pattern, where we moved all fetching logic outside of the UI component. Now, the component is focused solely on rendering the UI.
React Query and the Common Query Separation Pattern
When working with an API and React Query, there are two main hooks: useQuery
and useMutation
. The useQuery
hook fetches data and uses caching to optimize fetching logic. The useMutation
hook is primarily for data mutation. These hooks work differently, and the separation of these hooks is intentional.
In real-world codebases, when abstracting React Query, it's common to have one hook where both fetching and mutating data happen:
export const useUser = (userId) => {
const queryClient = useQueryClient();
// Fetch all users
const usersQuery = useQuery(['users'], fetchUsers, {
staleTime: 30000, // Cache data for 30 seconds
refetchOnWindowFocus: false, // Disable refetch on window focus
});
// Fetch a single user by ID (optional)
const userQuery = useQuery(['user', userId], () => fetchUserById(userId), {
enabled: !!userId, // Only run this query if a userId is provided
});
// Mutation for updating user data
const mutation = useMutation(updateUser, {
onSuccess: () => {
// Invalidate and refetch users data after a successful update
queryClient.invalidateQueries(['users']);
if (userId) {
queryClient.invalidateQueries(['user', userId]);
}
},
});
// Function to update a user
const updateUserDetails = (userId, updatedData) => {
mutation.mutate({ userId, updatedData });
};
return {
users: usersQuery.data,
isLoadingUsers: usersQuery.isLoading,
isErrorUsers: usersQuery.isError,
user: userQuery.data,
isLoadingUser: userQuery.isLoading,
isErrorUser: userQuery.isError,
updateUserDetails,
isUpdating: mutation.isLoading,
updateError: mutation.isError,
};
};
At first glance, there is nothing wrong with this hook, but "updating" data often requires more logic, such as preparing data for the server, handling specific errors like validation, etc.
One pattern we can apply here is "Common Query Separation," which is commonly used on the backend side.
Command Query Separation (CQS) is a design principle used in software development, particularly in object-oriented programming, that distinguishes between methods that perform actions (commands) and methods that return data (queries).
The main principle here is separating get
logic from mutation
(or commands
in the original context). In React, we don't directly mutate API data, but with React Query, we handle this separation using the useQuery
and useMutation
hooks.
After applying this pattern, we can divide the useUser
hook into two separate hooks:
const useUserQuery = (userId) => {
// Fetch a single user by ID (optional)
const {data: user, isLoading, isError} = useQuery(['user', userId], () => fetchUserById(userId), {
enabled: !!userId, // Only run this query if a userId is provided
});
return {
user,
isLoading,
isError,
};
};
const useUserMutation = () => {
const queryClient = useQueryClient();
// Mutation for updating user data
const mutation = useMutation(updateUser, {
onSuccess: (_, { userId }) => {
// Invalidate and refetch users data after a successful update
queryClient.invalidateQueries(['users']);
if (userId) {
queryClient.invalidateQueries(['user', userId]);
}
},
});
return {
updateUserDetails: mutation.mutate,
isUpdating: mutation.isLoading,
isError: mutation.isError,
};
};
This separation bring us benefits like:
- Modularity: Each hook is focused on a single responsibility, making the logic easier to manage and test.
-
Reusability: You can reuse
useUserQuery
anduseUserMutation
independently in other parts of your application. - Scalability: As your application grows, separating concerns helps in maintaining and scaling the codebase efficiently.
For showing other developers what this hooks doing it's better to add postfix with description what this hook doing. It could be Query
and Mutation
or more related postfixes to your codebase.
Conclusion
Developers could face the problem of complex components where mix of the different logic and rendering UI is not a biggest part of it. By leveraging patterns like Separation of Concerns and Common Query Separation, you can create a clean and modular architecture that keeps your components focused on rendering the UI while delegating data-fetching and mutation logic to custom hooks.
Incorporating these principles into your React development workflow will help you manage the complexity of state and server synchronization effectively, ensuring your application remains easy to understand and extend as it evolves.
Top comments (0)