Tutorial 2.2.2

State & Data Flow: React Query, Context & Optimistic Updates

Chapter 5 · Client‑Server Interaction
~2.5 hours Advanced React Query · Optimistic · Context

Overview

Once you start fetching data, managing its state across your application becomes critical. This tutorial covers lifting state up, distinguishing server state from client state, using React Query (TanStack Query) for powerful data synchronisation, implementing optimistic updates for a snappy UI, and managing global state with Context or Zustand.

Why this matters: Proper data flow and state management prevent bugs, reduce unnecessary re‑renders, and create a fluid user experience. Tools like React Query transform how you think about server state.

1. Lifting State Up

When multiple components need to share the same data, the state should be moved (lifted) to their closest common ancestor. This ancestor then passes the data and an update function down via props.

// Parent component manages the state function App() { const [users, setUsers] = useState([]); const addUser = (user) => setUsers([...users, user]); return ( <> <UserList users={users} /> <UserForm onSubmit={addUser} /> </> ); } // Children receive data and callbacks via props

This works well for small to medium apps, but becomes cumbersome with deep nesting or many components – hence the need for Context or state management libraries.

2. Server vs Client State

It's crucial to distinguish between these two types of state:

  • Client state: UI state (modals open/closed, form inputs, dark mode). Stored in React state or local storage.
  • Server state: Data persisted on the server (posts, users, comments). Requires asynchronous fetching, caching, re‑validation, and synchronization.

Managing server state with useState + useEffect is error‑prone (race conditions, caching, re‑fetching). Libraries like React Query handle this for you.

3. React Query (TanStack Query)

React Query is a powerful library that handles server‑state management: caching, background updates, retries, pagination, and more.

Setup

npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient(); function App() { return ( <QueryClientProvider client={queryClient}> <Users /> </QueryClientProvider> ); }

useQuery – fetching data

import { useQuery } from '@tanstack/react-query'; function Users() { const { data, isLoading, error } = useQuery({ queryKey: ['users'], queryFn: () => axios.get('/api/users').then(res => res.data), }); if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return <ul>{data.map(...)}</ul>; }

useMutation – modifying data

import { useMutation, useQueryClient } from '@tanstack/react-query'; function AddUser() { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (newUser) => axios.post('/api/users', newUser), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); }, }); const handleSubmit = (user) => mutation.mutate(user); // ... }
Key concepts: queryKey uniquely identifies cached data. invalidateQueries triggers a refetch. React Query handles caching, deduping, and background refetching automatically.

4. Optimistic Updates

Optimistic updates update the UI immediately, before the server confirms the change. If the server request fails, we roll back to the previous state. This makes the UI feel instant and responsive.

const mutation = useMutation({ mutationFn: (newPost) => axios.post('/api/posts', newPost), onMutate: async (newPost) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['posts'] }); // Snapshot previous value const previousPosts = queryClient.getQueryData(['posts']); // Optimistically update the cache queryClient.setQueryData(['posts'], (old) => [...old, newPost]); // Return rollback context return { previousPosts }; }, onError: (err, newPost, context) => { // Rollback on error queryClient.setQueryData(['posts'], context.previousPosts); }, onSettled: () => { // Always refetch after success or error queryClient.invalidateQueries({ queryKey: ['posts'] }); }, });

This pattern is used in many applications (e.g., liking a post, adding a comment) to provide a seamless user experience.

5. Global State (Context / Zustand)

For client‑side state that is needed across many components (e.g., user theme, authentication status), you can use the Context API or a dedicated state management library like Zustand or Redux.

Context API (built‑in)

const AuthContext = createContext(); function AuthProvider({ children }) { const [user, setUser] = useState(null); return ( <AuthContext.Provider value={{ user, setUser }}> {children} </AuthContext.Provider> ); } function useAuth() { return useContext(AuthContext); }

Zustand (lightweight alternative)

import { create } from 'zustand'; const useStore = create((set) => ({ user: null, setUser: (user) => set({ user }), })); // Usage in component const user = useStore((state) => state.user);

Recommendation: Use React Query for server state. Use Context or Zustand for client state (theme, auth, modals). This separation keeps your application clean and performant.

Quiz

Question 1

What is the primary purpose of React Query?

  • To manage UI state
  • To handle server‑state (caching, fetching, synchronisation)
  • To replace Redux for all state
  • To handle routing
Show answer
B. To handle server‑state (caching, fetching, synchronisation).

Question 2

Which React Query method is used to trigger a refetch of queries after a mutation?

  • refetchQueries
  • invalidateQueries
  • resetQueries
  • clearQueries
Show answer
B. invalidateQueries.

Question 3

What does an optimistic update do?

  • It waits for the server response before updating the UI
  • It updates the UI immediately and rolls back if the server fails
  • It updates the server without changing the UI
  • It disables the UI during the request
Show answer
B. It updates the UI immediately and rolls back if the server fails.

Exercises

Exercise 1

Write a React Query useQuery hook to fetch a list of posts from /api/posts with a query key of ['posts']. Show loading and error states.

Sample answer
const { data, isLoading, error } = useQuery({
  queryKey: ['posts'],
  queryFn: () => axios.get('/api/posts').then(res => res.data),
});

if (isLoading) return <div>Loading posts...</div>;
if (error) return <div>Error loading posts: {error.message}</div>;
return <ul>{data.map(post => <li key={post.id}>{post.title}</li>)}</ul>;

Exercise 2

Explain the role of onMutate in a React Query mutation for optimistic updates.

Sample answer

onMutate runs before the actual mutation function. It allows you to:

  • Cancel any outgoing refetches to avoid overwriting the optimistic update.
  • Snapshot the current cache state.
  • Optimistically update the cache with the new data.
  • Return a context object (e.g., the snapshot) to be used in onError for rollback.

Homework

Homework 1

Create a TodoList component that uses React Query to fetch and display todos. Implement a mutation to add a new todo with optimistic updates. Ensure that if the add request fails, the UI rolls back to the previous state.

Sample answer (skeleton)

Fetch:

const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });

Mutation:

const mutation = useMutation({
  mutationFn: addTodo,
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries(['todos']);
    const previous = queryClient.getQueryData(['todos']);
    queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
    return { previous };
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previous);
  },
  onSettled: () => {
    queryClient.invalidateQueries(['todos']);
  },
});

Mini‑Project

Collaborative Task Board (Kanban)

Build a simple task board with three columns: "To Do", "In Progress", "Done".

  • Fetch tasks from a mock API using React Query.
  • Allow dragging a task to a different column (or clicking a button to move it).
  • Implement optimistic updates for moving tasks.
  • Use Context or Zustand for UI state (e.g., which column is expanded).
Sample architecture

Data structure:

// Each task: { id, title, status: 'todo' | 'inProgress' | 'done' }

React Query:

  • useQuery(['tasks'], fetchTasks)
  • useMutation(updateTaskStatus) with optimistic update that modifies the task's status in the cache.

Optimistic update:

onMutate: async ({ taskId, newStatus }) => {
  await queryClient.cancelQueries(['tasks']);
  const previous = queryClient.getQueryData(['tasks']);
  queryClient.setQueryData(['tasks'], (old) =>
    old.map(task => task.id === taskId ? { ...task, status: newStatus } : task)
  );
  return { previous };
}

UI state: Use Zustand to store which columns are collapsed/expanded.

Tutorial Summary

This tutorial covered the essential patterns for managing data flow in React applications. You learned about lifting state, distinguishing server from client state, using React Query for robust data synchronisation, implementing optimistic updates for a responsive UI, and managing global state with Context or Zustand. The mini‑project brought these patterns together in a real‑world scenario.

Key takeaway: Separate your concerns: React Query for server state, React state or Context/Zustand for client state. Leverage optimistic updates to make your app feel instantaneous while maintaining data integrity.