Web Animations: Framer Motion and CSS for React Developers
A practical guide to animations in React — CSS transitions and keyframes, Framer Motion for complex interactions, performance optimization, and accessibility considerations.
A practical guide to modern React state management — TanStack Query for server state, Zustand for client state, and patterns that replaced Redux for most applications.
By codefunc
State management in React used to mean Redux for everything — actions, reducers, thunks, and boilerplate. In 2026, the ecosystem has matured. The key insight: server state (data from APIs) and client state (UI state like "is the sidebar open?") are different problems requiring different solutions.
Bottom line: Use TanStack Query (or SWR) for server state — it handles caching, background refetching, and loading states automatically. Use Zustand (or Jotai) for client state — it is simpler than Redux with less boilerplate. Use React Context for dependency injection, not state management. Reserve Redux for complex apps with established patterns.
| Library | Best for | Complexity |
|---|---|---|
| TanStack Query | Server state (API data) | Low-medium |
| SWR | Server state (simpler API) | Low |
| Zustand | Client state (simple global) | Low |
| Jotai | Client state (atomic/derived) | Low-medium |
| Redux Toolkit | Complex client state, established codebases | Medium-high |
| React Context | Dependency injection, theming | Low |
| useState/useReducer | Component-local state | Low |
Data that lives on your backend and is fetched via API:
/api/users/me/api/posts/api/search?q=...Characteristics:
Data that lives only in the browser:
Characteristics:
TanStack Query (formerly React Query) is the standard for server state management. It handles caching, deduplication, background updates, and pagination automatically.
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={data} />;
}
Query keys identify cached data. Use arrays with identifiers:
// User by ID
queryKey: ['user', userId]
// Posts with filters
queryKey: ['posts', { status: 'published', page: 1 }]
// Nested resource
queryKey: ['user', userId, 'posts']
When any part of the key changes, the query refetches.
For creating, updating, or deleting data:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePost() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newPost) =>
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}).then(res => res.json()),
onSuccess: () => {
// Invalidate and refetch posts
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
mutation.mutate({ title: 'New Post', body: '...' });
}}>
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Update the UI before the server responds:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old) =>
old.map(todo => todo.id === newTodo.id ? newTodo : todo)
);
return { previousTodos };
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
function Posts() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
placeholderData: keepPreviousData, // Keep showing old data while fetching
});
return (
<>
<PostList posts={data?.posts} />
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
>
Previous
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data?.hasMore}
>
Next
</button>
</>
);
}
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const allPosts = data?.pages.flatMap(page => page.posts) ?? [];
Zustand is a minimal state management library. No providers, no boilerplate, just stores.
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
function Counter() {
const { count, increment, decrement } = useStore();
return (
<div>
<span>{count}</span>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
interface BearState {
bears: number;
increase: (by: number) => void;
}
const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}));
Only re-render when selected state changes:
// Component re-renders on any state change
const state = useStore();
// Component only re-renders when count changes
const count = useStore((state) => state.count);
// Multiple selectors
const { count, increment } = useStore(
(state) => ({ count: state.count, increment: state.increment }),
shallow // Compare shallowly
);
import { persist } from 'zustand/middleware';
const useStore = create(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{
name: 'app-storage', // localStorage key
}
)
);
const useStore = create((set) => ({
user: null,
loading: false,
fetchUser: async (id) => {
set({ loading: true });
try {
const user = await api.getUser(id);
set({ user, loading: false });
} catch (error) {
set({ loading: false });
throw error;
}
},
}));
Context is for dependency injection, not state management:
// Good: Theme, locale, auth context
const ThemeContext = createContext<Theme>('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Page />
</ThemeContext.Provider>
);
}
// Avoid: Frequently updating state
// Every consumer re-renders on every change
const CountContext = createContext({ count: 0, setCount: () => {} });
For component-local state, hooks are often enough:
// Simple state
const [isOpen, setIsOpen] = useState(false);
// Complex state with multiple related values
const [state, dispatch] = useReducer(reducer, initialState);
When siblings need to share state:
function Parent() {
const [selected, setSelected] = useState(null);
return (
<>
<List onSelect={setSelected} />
<Details item={selected} />
</>
);
}
If lifting becomes awkward, consider Zustand.
A typical app uses multiple approaches:
// Server state: TanStack Query
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: fetchCurrentUser,
});
// Global client state: Zustand
const theme = useThemeStore((s) => s.theme);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
// Local component state: useState
const [searchQuery, setSearchQuery] = useState('');
// Context: dependency injection
const api = useContext(ApiContext);
If you have a Redux codebase:
| Redux pattern | Modern replacement |
|---|---|
| API data in Redux | TanStack Query |
| Thunks for async | TanStack Query mutations |
| UI state slices | Zustand stores |
useSelector |
Zustand selectors |
| DevTools | Zustand devtools middleware |
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools((set) => ({
// ... store
}))
);
Works with Redux DevTools browser extension.
Before debugging state issues, ensure your API responses are valid. Format and inspect with the JSON Formatter. Validate structure with the JSON Validator. Generate test data with the Fake Data Generator.
| Mistake | Problem | Fix |
|---|---|---|
| Putting API data in Zustand | Duplicates TanStack Query features | Use TanStack Query for server state |
| Over-using Context | Re-renders entire tree | Use Zustand for frequently changing state |
| Prop drilling instead of state | Cumbersome, error-prone | Lift to Zustand when 3+ levels deep |
| Not invalidating queries | Stale data after mutations | Call invalidateQueries in onSuccess |
| Fetching in useEffect | Missing loading/error/caching | Use TanStack Query |
Server state and client state need different tools. TanStack Query handles caching, refetching, and synchronization with your backend automatically. Zustand provides simple global state without Redux ceremony. Context injects dependencies, not state. useState handles local concerns. Pick the right tool for each type of state — most apps need a combination of all four approaches.
A practical guide to animations in React — CSS transitions and keyframes, Framer Motion for complex interactions, performance optimization, and accessibility considerations.
A practical guide to authentication for frontend developers — understanding OAuth flows, JWT structure, session cookies, and implementing secure auth in React applications.
A practical guide to using GraphQL in frontend applications — writing queries and mutations, client-side caching with Apollo and urql, and patterns for React integration.
Find a developer tool