“Strategies for managing server state, optimistic mutations, and avoiding redundant re-renders in complex React and Next.js applications.”
State management in modern React applications has evolved dramatically. The days of putting every piece of remote data into a massive global Redux store are behind us.
Instead, contemporary React engineering separates state into four distinct, purposeful buckets:
- 01Server Cache State (React Query / TanStack Query, SWR)
- 02URL Search Param State (Next.js Router, custom query hooks)
- 03Form / Ephemeral State (React Hook Form, local
useState) - 04Global UI State (Zustand, Context for theme/auth)
1. Anti-Pattern: Syncing Server Data into Local State#
One of the most common pitfalls is pulling data from an API and immediately mirroring it into a local useState with useEffect:
// ❌ ANTI-PATTERN: Duplicated state & desynchronization
function BadUserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser(userId).then((data) => setUser(data));
}, [userId]);
// If external data updates or cache invalidates, this component is out of sync!
}Instead, treat server data as an asynchronous cache:
// ✅ RECOMMENDED: Dedicated query hooks with automatic caching
import { useQuery } from "@tanstack/react-query";
function CleanUserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ["users", userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5, // 5 minutes fresh
});
if (isLoading) return <ProfileSkeleton />;
if (error || !user) return <ErrorMessage error={error} />;
return <div>{user.name}</div>;
}2. Lifting Filter State to URL Search Params#
Filters, tab selections, search queries, and pagination should almost always live in the URL. This enables deep linking, bookmarks, and frictionless back/forward browser navigation:
import { useRouter } from "next/router";
export function useProjectFilters() {
const router = useRouter();
const activeTech = (router.query.tech as string) || "All";
const searchQuery = (router.query.q as string) || "";
const setTech = (tech: string) => {
router.push(
{
pathname: router.pathname,
query: { ...router.query, tech: tech === "All" ? [] : tech },
},
undefined,
{ shallow: true }
);
};
return { activeTech, searchQuery, setTech };
}3. Optimistic UI Updates for Instant Feedback#
When a user triggers an action (like starring a repo or archiving an item), waiting for server round-trips creates perceptible lag. Use optimistic rollbacks for instant response:
const mutation = useMutation({
mutationFn: toggleFavoriteProject,
onMutate: async (projectId) => {
// 1. Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: ["projects"] });
// 2. Snapshot previous value
const previousProjects = queryClient.getQueryData(["projects"]);
// 3. Optimistically update cache
queryClient.setQueryData(["projects"], (old: any) =>
old.map((p: any) =>
p.id === projectId ? { ...p, isFavorite: !p.isFavorite } : p
)
);
return { previousProjects };
},
onError: (err, newTodo, context) => {
// Rollback on failure
queryClient.setQueryData(["projects"], context?.previousProjects);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
},
});4. Key Takeaways#
- Derive state during render instead of computing and saving to duplicate state variables.
- Keep server cache, URL state, and UI toggles strictly separate.
- Implement optimistic updates with rollback guards to achieve native-like speed.
