If you've been developing React applications for any significant amount of time, you've likely encountered the challenge of state management. At some point, passing props becomes cumbersome, context starts to feel limiting, and you find yourself looking for more robust solutions. This is where libraries like Redux and React Query enter the picture – but they approach the problem from fundamentally different angles.
Having led multiple cross-functional teams through this exact decision process, I've seen firsthand how choosing the right data management approach can make or break a project. Today, I want to share that experience with you as we dive deep into Redux and React Query – examining their philosophies, strengths, weaknesses, and ideal use cases.
Understanding the Problem Space
Before we compare these libraries, let's establish a clear understanding of what problems they're trying to solve:
The Challenge: Modern web applications need to:
- Fetch data from servers
- Cache responses
- Handle loading and error states
- Keep UI in sync with backend data
- Manage local application state
- Maintain predictable state transitions
- Ensure good performance even with complex state
The Question: How do we approach these challenges in a way that's maintainable, performant, and doesn't result in spaghetti code?
Redux: A Global State Container
Redux was born during the early days of React when developers were struggling with state management across large component trees. It introduced a highly structured approach to state management.
Core Concepts of Redux
-
Single Source of Truth: Your entire application state lives in one JavaScript object called the "store."
-
State is Read-Only: The only way to change state is to emit an "action" – an object describing what happened.
-
Changes Are Made With Pure Functions: Reducers are pure functions that take the previous state and an action, then return the next state.
Let's look at a basic Redux example to refresh our understanding:
// Action Types
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
// Action Creators
const addTodo = (text) => ({
type: ADD_TODO,
payload: { text, id: Date.now(), completed: false }
});
const toggleTodo = (id) => ({
type: TOGGLE_TODO,
payload: { id }
});
// Reducer
const initialState = { todos: [] };
function todoReducer(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return {
...state,
todos: [...state.todos, action.payload]
};
case TOGGLE_TODO:
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
};
default:
return state;
}
}
// Store usage with React-Redux
import { useSelector, useDispatch } from 'react-redux';
function TodoList() {
const todos = useSelector(state => state.todos);
const dispatch = useDispatch();
return (
<div>
<button onClick={() => dispatch(addTodo("New task"))}>Add Todo</button>
<ul>
{todos.map(todo => (
<li
key={todo.id}
onClick={() => dispatch(toggleTodo(todo.id))}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
>
{todo.text}
</li>
))}
</ul>
</div>
);
}
Redux for Server Data
When we use Redux for server data, we typically:
- Create actions for API requests (REQUEST_DATA, RECEIVE_DATA, ERROR_DATA)
- Use middleware like Redux Thunk or Redux Saga to handle asynchronous logic
- Store server responses in our Redux store
- Manage loading and error states manually
Here's a simple example using Redux Thunk:
// Action Types
const FETCH_USERS_REQUEST = 'FETCH_USERS_REQUEST';
const FETCH_USERS_SUCCESS = 'FETCH_USERS_SUCCESS';
const FETCH_USERS_FAILURE = 'FETCH_USERS_FAILURE';
// Action Creators
const fetchUsersRequest = () => ({ type: FETCH_USERS_REQUEST });
const fetchUsersSuccess = (users) => ({ type: FETCH_USERS_SUCCESS, payload: users });
const fetchUsersFailure = (error) => ({ type: FETCH_USERS_FAILURE, payload: error });
// Async Action Creator (using Redux Thunk)
const fetchUsers = () => {
return async (dispatch) => {
dispatch(fetchUsersRequest());
try {
const response = await fetch('https://api.example.com/users');
const data = await response.json();
dispatch(fetchUsersSuccess(data));
} catch (error) {
dispatch(fetchUsersFailure(error.message));
}
};
};
// Reducer
const initialState = {
users: [],
loading: false,
error: null
};
function usersReducer(state = initialState, action) {
switch (action.type) {
case FETCH_USERS_REQUEST:
return { ...state, loading: true, error: null };
case FETCH_USERS_SUCCESS:
return { ...state, loading: false, users: action.payload };
case FETCH_USERS_FAILURE:
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
Question: What are the downsides of managing server data with Redux?
The answer is that it requires a lot of boilerplate code. For each API endpoint, we need multiple action types, action creators, and reducer cases. We also need to manually handle caching, deduplication of requests, and data staleness. These are server-data-specific concerns that Redux doesn't address out of the box.
React Query: A Server State Library
React Query emerged as a response to a key insight: server state is fundamentally different from client state. Server data has unique characteristics:
- It's remotely persisted and not owned by the client
- It requires asynchronous APIs for fetching and updating
- It can become stale while users interact with your app
- It's shared across different parts of your application
Core Concepts of React Query
- Queries: Declarative dependencies to fetch data
- Mutations: Functions to update data
- Query Invalidation: A mechanism to mark data as stale
- Automatic Caching: Built-in caching of query results
- Automatic Refetching: Background refreshing of stale data
Let's see React Query in action:
import { useQuery, useMutation, useQueryClient } from 'react-query';
// A custom hook for fetching users
function useUsers() {
return useQuery('users', async () => {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
}
// Component using the hook
function UserList() {
const { data: users, isLoading, error } = useUsers();
const queryClient = useQueryClient();
// Mutation for adding a user
const addUserMutation = useMutation(
async (newUser) => {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
return response.json();
},
{
// When mutation succeeds, invalidate and refetch
onSuccess: () => {
queryClient.invalidateQueries('users');
},
}
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<button
onClick={() => addUserMutation.mutate({ name: 'New User' })}
disabled={addUserMutation.isLoading}
>
Add User
</button>
{addUserMutation.isError && (
<div>Error adding user: {addUserMutation.error.message}</div>
)}
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
What Did We Just Get "For Free"?
- Automatic loading and error states
- Caching of query results
- Deduplication of requests
- Background refetching of stale data
- Optimistic UI updates
- Retry logic on failures
- Prefetching capabilities
Question: Does this mean we shouldn't use Redux at all?
Not at all! Redux still excels at managing client state that:
- Needs to be accessed by many components
- Requires complex state transitions
- Benefits from middleware for side effects
- Needs time-travel debugging
Key Differences in Philosophy
Understanding the philosophical differences between these libraries helps clarify when to use each:
Redux:
- Prescriptive: Enforces a specific pattern for all state changes
- Centralized: All state lives in one store
- Explicit: Every state change is tracked through actions
- Generic: Treats all state the same way, whether local or from a server
- Debuggable: Time-travel debugging and action logs
- Middleware-focused: Relies on middleware for side effects
React Query:
- Declarative: You describe what data you need, not how to fetch it
- Data-centric: Focused specifically on server state
- Opinionated about server data: Built-in solutions for caching, staleness, etc.
- Less code: Significantly reduces boilerplate for data fetching
- UI-centric: Designed to sync UI with server state efficiently
- Optimized for async: Built from the ground up for asynchronous operations
Case Studies: When to Use Each
Let's look at some real-world scenarios I've encountered where one library clearly outshined the other:
Case 1: E-commerce Product Catalog
Scenario: A catalog with thousands of products, filters, sorting options, and pagination.
Recommendation: React Query
Reasoning:
- Product data comes from a server and isn't owned by the client
- React Query's caching avoids refetching data when navigating between pages
- Automatic background refreshes ensure prices and inventory are current
- Pagination and filtering are handled elegantly with query parameters
Implementation Example:
function ProductCatalog() {
const [page, setPage] = useState(1);
const [filters, setFilters] = useState({});
const { data, isLoading, error } = useQuery(
['products', page, filters],
() => fetchProducts(page, filters),
{
keepPreviousData: true, // Show previous page while loading next
staleTime: 5 * 60 * 1000, // Consider data stale after 5 minutes
}
);
// Component implementation...
}
Case 2: Complex Form Builder
Scenario: A form builder tool where users can create custom forms with conditional logic, validations, and multiple question types.
Recommendation: Redux
Reasoning:
- The form structure is pure client state with complex relationships
- Multiple components need to access and modify the form configuration
- We need to track every state change for undo/redo functionality
- The state transitions follow predictable patterns suitable for reducers
Implementation Example:
// Reducer for form builder
function formBuilderReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_QUESTION':
return {
...state,
questions: [...state.questions, createDefaultQuestion()]
};
case 'UPDATE_QUESTION':
return {
...state,
questions: state.questions.map(q =>
q.id === action.payload.id ? { ...q, ...action.payload.updates } : q
)
};
case 'ADD_CONDITION':
// Complex logic for adding conditional logic
return {
// ...implementation
};
// Many more action types...
default:
return state;
}
}
Case 3: Analytics Dashboard
Scenario: A dashboard showing real-time analytics with multiple data visualizations.
Recommendation: React Query + Redux
Reasoning:
- React Query handles the fetching, caching, and refreshing of analytics data
- Redux manages UI state like which charts are visible, time ranges, and user preferences
- This hybrid approach leverages the strengths of both libraries
Implementation Example:
function AnalyticsDashboard() {
// Redux for UI state
const timeRange = useSelector(state => state.dashboard.timeRange);
const visibleCharts = useSelector(state => state.dashboard.visibleCharts);
const dispatch = useDispatch();
// React Query for data fetching
const { data: revenueData } = useQuery(
['revenue', timeRange],
() => fetchRevenueData(timeRange),
{ refetchInterval: 60000 } // Refetch every minute
);
const { data: userActivityData } = useQuery(
['userActivity', timeRange],
() => fetchUserActivity(timeRange),
{ refetchInterval: 30000 } // Refetch every 30 seconds
);
// Component implementation...
}
Common Questions and Solutions
Q1: "I'm already using Redux. Should I migrate to React Query?"
A: You don't need to make an either/or choice. Consider these options:
-
Hybrid Approach: Use React Query for server state and Redux for complex client state.
-
Gradual Migration: Start using React Query for new features while keeping existing Redux code.
-
Evaluate Complexity: If your Redux code for server data is simple and working well, the migration benefit might be minimal.
Solution Example:
// Store configuration with Redux Toolkit
import { configureStore } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux';
import { QueryClient, QueryClientProvider } from 'react-query';
// Redux slices
import uiReducer from './slices/uiSlice';
import preferencesReducer from './slices/preferencesSlice';
// Configure Redux store
const store = configureStore({
reducer: {
ui: uiReducer,
preferences: preferencesReducer,
// Notice: no API data reducers here anymore
}
});
// Configure React Query client
const queryClient = new QueryClient();
// Application setup
function App() {
return (
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<YourApplication />
</Provider>
</QueryClientProvider>
);
}
Q2: "How do I handle dependent queries in React Query?"
A: React Query supports dependent queries where one query depends on the result of another.
Solution:
function UserPosts() {
// First query - get current user
const { data: user } = useQuery('user', fetchCurrentUser);
// Second query - get posts for user, but only when user is available
const { data: posts } = useQuery(
['posts', user?.id],
() => fetchPostsByUser(user.id),
{
// Only execute this query when user id exists
enabled: !!user?.id,
}
);
if (!user) return <div>Loading user...</div>;
if (!posts) return <div>Loading posts...</div>;
return (
<div>
<h1>{user.name}'s Posts</h1>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
Q3: "How do I implement optimistic updates with React Query?"
A: React Query makes optimistic updates straightforward with the onMutate
callback.
Solution:
function TodoList() {
const queryClient = useQueryClient();
const { data: todos } = useQuery('todos', fetchTodos);
const updateTodoMutation = useMutation(
(updatedTodo) => updateTodo(updatedTodo),
{
// When mutation is triggered
onMutate: async (newTodo) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries('todos');
// Snapshot the previous value
const previousTodos = queryClient.getQueryData('todos');
// Optimistically update to the new value
queryClient.setQueryData('todos', old =>
old.map(todo => todo.id === newTodo.id ? newTodo : todo)
);
// Return context with the previous data
return { previousTodos };
},
// If mutation fails, use context to roll back
onError: (err, newTodo, context) => {
queryClient.setQueryData('todos', context.previousTodos);
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries('todos');
},
}
);
// Component implementation...
}
Q4: "Redux has DevTools. What about debugging React Query?"
A: React Query has its own DevTools component that provides visibility into your queries and cache.
Solution:
import { ReactQueryDevtools } from 'react-query/devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApplication />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Performance Considerations
When evaluating these libraries for your project, consider these performance aspects:
Redux:
-
Pros:
- Highly optimized for state updates
- Memoization patterns well established
- Powerful with Redux Toolkit
-
Cons:
- Can lead to unnecessary re-renders without careful design
- Large stores can impact performance
- Requires middleware for async operations
React Query:
-
Pros:
- Built-in request deduplication and caching
- Automatic garbage collection of unused queries
- Structural sharing to prevent unnecessary renders
-
Cons:
- Can over-fetch if query keys aren't designed properly
- Potential memory consumption from large cached datasets
- Learning curve for optimizing complex query patterns
Decision Framework
To help you make an informed choice, consider these questions:
-
Data Origin:
- Is most of your state derived from a server? → React Query
- Is most of your state client-generated? → Redux
- Both in significant amounts? → Consider both
-
Update Patterns:
- Do you need optimistic updates with rollback? → React Query excels here
- Do you need time-travel debugging? → Redux is better
- Need both? → Use both libraries for different parts
-
Team Experience:
- Is your team already proficient with Redux? → Consider the learning curve
- Are you starting fresh? → React Query has less boilerplate
-
Project Type:
- Content-heavy application (blog, e-commerce)? → React Query
- Complex state logic (form builder, editor)? → Redux
- Interactive dashboards? → Possibly both
Conclusion
The choice between React Query and Redux isn't binary. They solve different problems:
- Redux excels at managing complex client state with predictable transitions
- React Query excels at synchronizing server data with your UI
Many modern React applications benefit from using both libraries in tandem, with each managing the type of state it's best suited for.
As with many architectural decisions, context matters enormously. Consider your specific requirements, team experience, and performance needs when making your choice.
What's Your Take?
As we continue to see the evolution of state management in React, one thing seems clear: specialized tools for specific types of state management tend to outperform general-purpose solutions. But this raises an intriguing question:
As our tools become more specialized and opinionated, are we moving toward a future where the distinction between client and server state blurs completely, or will we continue to need different paradigms for different types of state?