Frontend Architecture
ReactTypeScriptFrontendState Management

Clean State Architecture in React 18: Avoiding Common Anti-Patterns

Sathya Seelan
Sathya Seelan
Full-Stack Developer
November 08, 2024•6 min read

“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:

  1. 01Server Cache State (React Query / TanStack Query, SWR)
  2. 02URL Search Param State (Next.js Router, custom query hooks)
  3. 03Form / Ephemeral State (React Hook Form, local useState)
  4. 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:

typescript
// ❌ 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:

typescript
// ✅ 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:

typescript
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:

typescript
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.