Modern State Management in React: Stop Using Redux for Server Data

I want to tell you about a “State Management Architect.” That was his self-appointed title. Three years ago, I joined a project where this person had decided that every single byte of data — from the user’s middle name to the “is hovered” state of a tooltip — needed to live in a global Redux store.

We had 450 actions. Reducers that looked like the genealogy trees of European royalty. To change a checkbox, you had to fire an action, trigger a saga, wait for a side effect, update a reducer, and hope the selector didn’t cause a re-render that crashed the browser.

We spent three months building a “caching layer” on top of Redux because the API calls were too slow. We essentially re-invented a database inside the browser, manually keeping it in sync with a Spring Boot backend. When the internet flickered for a millisecond, the “source of truth” became a lie.

We spent the developer-hour equivalent of a six-figure salary to build a system less reliable than a window.location.reload().


The State Management Lies We Keep Telling Ourselves

Lie #1: “The client is the source of truth.” It isn’t. Your database is the truth. Your client is a temporary, unreliable cached view of that truth. The moment you treat server state — data that lives in the DB — the same as UI state — whether a modal is open — you’ve already lost the battle.

Lie #2: “I need global state for scalability.” You need it because you don’t know how to pass props or you’re afraid of the network tab. 90% of what ends up in global state is just server data you’re manually caching because you don’t trust the browser.

Lie #3: “Writing my own fetch wrapper makes the app cleaner.” Your wrapper doesn’t handle deduplication. It doesn’t handle window-focus refetching. It definitely doesn’t handle the race conditions you haven’t discovered yet. You’re not building a framework; you’re building technical debt.


Phase 1: The useEffect Trap

The first instinct is to control everything manually:

const UserProfile = ({ userId }: { userId: string }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => { setUser(data); setLoading(false); })
.catch((err) => { setError(err.message); setLoading(false); });
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
};

This looks like a tutorial example because it is. In production: if the user clicks “Profile” ten times, you fire ten requests. If they navigate away mid-fetch, you update state on an unmounted component. There’s no caching. No retry logic. It’s a fragile hope disguised as code.

Phase 2: The Redux Over-Engineering Trap

The developer discovers Redux Toolkit and wants to be “organized.” 40 lines of boilerplate later:

export const fetchUser = createAsyncThunk('users/fetchById', async (userId: string) => {
const response = await userApi.fetchById(userId);
return response.data;
});
const userSlice = createSlice({
name: 'users',
initialState: { data: {}, status: 'idle' },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => { state.status = 'loading'; })
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'succeeded';
state.data[action.payload.id] = action.payload;
});
},
});

You’ve done exactly the same thing as the useEffect hook but with 40 extra lines and a global object eating memory for data users will never look at again. Cache invalidation: not solved. Background refetching: not solved. This isn’t architecture — it’s a tax.


The Modern Approach: Separate Server State from UI State

The key insight from 2025 onwards: server data is not your state — it belongs to the network. TanStack Query (React Query) is built entirely on this principle.

Step 1: Let the Library Own the Cache

import { useQuery } from '@tanstack/react-query';
const fetchUser = async (id: string): Promise<User> => {
const response = await fetch(`/api/v1/users/${id}`);
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
};
export const useUser = (id: string) => {
return useQuery({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
staleTime: 1000 * 60 * 5,
});
};

TanStack Query manages the cache. If two components need the same user, they share one request. No actions, no reducers, no status: 'idle' nonsense. The state is gone from your component.

Step 2: Hydration with Spring Data Projections

The “Flash of Loading” is the bane of SPAs. In 2025, we solve it by pre-populating the cache with data sent from the server — pairing TanStack Query with Spring Data Projections.

On the Spring side, send only what the UI needs:

public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u.id as id, u.username as username, u.email as email FROM User u WHERE u.id = :id")
UserProjection findProjectedById(Long id);
}

On the React side, seed the cache before the first render:

export const UserPage = ({ serverUserData: any, userId: number }) => {
const queryClient = useQueryClient();
// Prefill the cache with data from the server-side render
queryClient.setQueryData(['users', userId], serverUserData);
const { data: user } = useUser(userId);
return <div>{user.username}</div>;
};

No waiting for useEffect to fire after JS loads. The cache is already warm. No loading spinner, zero manual synchronization.

Step 3: Optimistic Updates Done Correctly

Users hate waiting. The UI should change immediately on interaction, while the server catches up. But we need to handle the case where the server disagrees.

export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (update: UserUpdate) =>
fetch(`/api/v1/users/${update.id}`, {
method: 'PATCH',
body: JSON.stringify(update),
}),
onMutate: async (newUser) => {
// 1. Cancel outgoing fetches so they don't overwrite the optimistic update
await queryClient.cancelQueries({ queryKey: ['users', newUser.id] });
// 2. Save the rollback snapshot
const previousUser = queryClient.getQueryData(['users', newUser.id]);
// 3. Update the UI immediately
queryClient.setQueryData(['users', newUser.id], (old: any) => ({ ...old, ...newUser }));
return { previousUser };
},
onError: (err, newUser, context) => {
// 4. Roll back silently on server failure
queryClient.setQueryData(['users', newUser.id], context?.previousUser);
},
onSettled: (newUser) => {
// 5. Always refetch to confirm we're in sync with the DB
queryClient.invalidateQueries({ queryKey: ['users', newUser?.id] });
},
});
};

This is bulletproof. No Redux action required.

Step 4: Tuning for Enterprise

The most common mistake is leaving all defaults untouched. Data freshness is a domain-specific decision:

const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 30, // 30 seconds
gcTime: 1000 * 60 * 60, // 1 hour
retry: (failureCount, error: any) => {
if (error.status === 404) return false;
return failureCount < 3;
},
refetchOnWindowFocus: process.env.NODE_ENV === 'production',
},
},
});

staleTime is your friend. On a high-traffic enterprise dashboard, you might want it at 0. On a documentation site, an hour. Configure based on your domain, not the defaults.



On the “Redux Provides Centralized Middleware” Argument

When was the last time you looked at Redux DevTools and it helped you find a bug that wasn’t caused by Redux itself?

“But what if I have highly interdependent data that needs updating across 10 different pages?” Then your API design is the problem, not your state management. If your frontend performs complex relational joins in memory, you’ve failed at the backend layer. Return a proper Spring projection with exactly what the UI needs, and let TanStack Query cache it. Stop building a database in the browser.


Actionable Steps to Simplify Your State

  1. Audit your global state. Every object that comes from an API is Server State. Move it into useQuery hooks.
  2. Delete the reducers. Remove anything that’s just caching API responses. That’s TanStack Query’s job now.
  3. Use meaningful query keys. Keys like ['orders', orderId, 'items'] make cache invalidation precise and predictable.
  4. Implement optimistic updates only where it matters. Not every interaction needs it — only the buttons users click most.
  5. Trust the cache. Configure staleTime for your domain and stop manually refetching things that haven’t changed.

Modern state management isn’t about finding a bigger box for your variables. It’s about realizing that most of those variables shouldn’t be your responsibility in the first place.

Offload synchronization to a tool better at it than you are. Spend that time building actual features, or going home on time. Go delete some code you don’t need.


Discover more from The Dev World – Sergio Lema

Subscribe to get the latest posts sent to your email.


Comments

Leave a comment