React Hooks: The State Management Model
For .NET engineers who know:
INotifyPropertyChanged,OnInitializedAsync,IDisposable, and constructor-injected services You’ll learn: How React’s hook system maps to .NET’s state and lifecycle patterns, where the analogies hold, and where they break in ways that will cost you hours if you do not know about them upfront Time: 20 min read
The .NET Way (What You Already Know)
In Blazor, state management and lifecycle are class-based concerns. A component inherits from ComponentBase, overrides lifecycle methods, declares private fields for state, and calls StateHasChanged() to trigger re-renders. Services arrive via constructor injection (or @inject). Subscriptions created during initialization are cleaned up in Dispose.
// A realistic Blazor component with lifecycle, state, and DI
@inject IUserService UserService
@inject ILogger<UserProfile> Logger
@implements IDisposable
<div>
@if (_isLoading) {
<Spinner />
} else if (_user is not null) {
<UserCard User="_user" />
}
</div>
@code {
[Parameter] public int UserId { get; set; }
private User? _user;
private bool _isLoading = true;
private CancellationTokenSource _cts = new();
protected override async Task OnInitializedAsync()
{
try
{
_user = await UserService.GetByIdAsync(UserId, _cts.Token);
}
finally
{
_isLoading = false;
}
}
protected override async Task OnParametersSetAsync()
{
if (UserId != _user?.Id)
{
_isLoading = true;
_user = await UserService.GetByIdAsync(UserId, _cts.Token);
_isLoading = false;
}
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
This is a complete, correct pattern. React hooks achieve the same result with a different mechanism — one that takes about 30 minutes to understand and about 30 days to stop making mistakes with.
The React Way
The Rules of Hooks (Why They Exist)
Before the individual hooks, understand the constraint they operate under. React tracks hooks by call order. Every time the component function runs (every render), React expects the same hooks to be called in the same order. This is why the rules exist:
Rule 1: Call hooks only at the top level. Not inside if statements, loops, or nested functions.
Rule 2: Call hooks only from React function components or other custom hooks. Not from plain utility functions or class components.
// WRONG — conditional hook call
function Profile({ userId, isAdmin }: ProfileProps) {
if (isAdmin) {
const [adminState, setAdminState] = useState(null); // Breaks the rule
}
// ...
}
// CORRECT — hook called unconditionally, condition is inside
function Profile({ userId, isAdmin }: ProfileProps) {
const [adminState, setAdminState] = useState<AdminData | null>(null);
if (isAdmin) {
// Use adminState here
}
}
The practical consequence: if you need a hook “only sometimes,” call it unconditionally and ignore its value when you do not need it. The eslint-plugin-react-hooks package enforces these rules at compile time — it should be installed in every project.
useState: The INotifyPropertyChanged Replacement
useState returns the current value and a setter. Calling the setter schedules a re-render. You already understand this from Article 3.1; here is the full picture:
import { useState } from "react";
function OrderForm() {
// Primitive state — string
const [customerName, setCustomerName] = useState<string>("");
// Object state — requires a new object on update (no mutation)
const [address, setAddress] = useState<Address>({
street: "",
city: "",
postalCode: "",
});
// Array state — requires a new array on update
const [lineItems, setLineItems] = useState<LineItem[]>([]);
// Boolean state — common for toggles
const [isSubmitting, setIsSubmitting] = useState(false);
// Lazy initializer — runs once, useful for expensive initial computation
// Pass a function, not a value: useState(() => computeInitialState())
const [cache, setCache] = useState<Map<string, string>>(() => new Map());
function updateCity(city: string) {
// Spread to create new object — mutation does not trigger re-render
setAddress((prev) => ({ ...prev, city }));
// ^ Functional update form: prev => next
// Prefer this when new state depends on old state
}
function addLineItem(item: LineItem) {
setLineItems((prev) => [...prev, item]);
}
function removeLineItem(id: string) {
setLineItems((prev) => prev.filter((item) => item.id !== id));
}
// ...
}
The functional update form (setCount(prev => prev + 1)) is important. If you call multiple setters in the same event handler, React batches them into a single re-render. But if the new state depends on the current state, use the functional form to ensure you are reading the most recent value, not a stale closure (more on this below).
useEffect: OnInitializedAsync + OnParametersSetAsync + Dispose
useEffect is where React’s lifecycle lives. It runs after the component renders and lets you perform side effects: data fetching, subscriptions, timers. Its second argument — the dependency array — controls when it runs.
import { useState, useEffect } from "react";
interface User {
id: string;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// This block runs after every render where userId has changed.
// On first render it always runs (equivalent to OnInitializedAsync).
let cancelled = false; // Equivalent to CancellationToken
async function fetchUser() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: User = await response.json();
// Guard against setting state on an unmounted component
// or after a newer fetch has started (equivalent to CancellationToken check)
if (!cancelled) {
setUser(data);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Unknown error");
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
fetchUser();
// Cleanup function — equivalent to IDisposable.Dispose
// Runs before the next effect execution, and when the component unmounts
return () => {
cancelled = true;
};
}, [userId]); // Dependency array — re-runs when userId changes
// ^^^^^^^^
// This is where most bugs live. See Gotchas.
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
The dependency array semantics:
| Dependency Array | When Effect Runs |
|---|---|
No array: useEffect(() => {}) | After every render — equivalent to OnAfterRender(true) |
Empty array: useEffect(() => {}, []) | Once after first render — equivalent to OnInitializedAsync |
With values: useEffect(() => {}, [x, y]) | After first render, then whenever x or y change |
React compares dependencies using Object.is — the same as === for primitives, reference equality for objects. If you pass an object or array into the dependency array that is re-created on every render, the effect will run on every render regardless.
useContext: Constructor Injection Without a DI Container
useContext is how you access shared data without prop drilling — the equivalent of resolving a service from the DI container. You define a context (the service contract), provide a value high in the component tree (the service registration), and consume it anywhere below.
// Step 1: Define the context type and create the context
// Equivalent to defining an IAuthService interface and a default stub
interface AuthContext {
currentUser: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
// The argument to createContext is the default value (used when no Provider is found)
const AuthContext = React.createContext<AuthContext>({
currentUser: null,
login: async () => {},
logout: () => {},
isAuthenticated: false,
});
// Step 2: The Provider component — equivalent to service registration in Program.cs
// Place this high in the tree, typically in App.tsx
function AuthProvider({ children }: { children: React.ReactNode }) {
const [currentUser, setCurrentUser] = useState<User | null>(null);
async function login(credentials: Credentials) {
const user = await authService.login(credentials);
setCurrentUser(user);
}
function logout() {
setCurrentUser(null);
}
// The value object — what components receive when they call useContext(AuthContext)
const value: AuthContext = {
currentUser,
login,
logout,
isAuthenticated: currentUser !== null,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// Step 3: Consume the context anywhere below the Provider
// Equivalent to constructor injection — the component does not need to know where the data comes from
function NavBar() {
const { currentUser, logout, isAuthenticated } = useContext(AuthContext);
return (
<nav>
{isAuthenticated ? (
<>
<span>Welcome, {currentUser?.name}</span>
<button onClick={logout}>Log out</button>
</>
) : (
<a href="/login">Log in</a>
)}
</nav>
);
}
A critical difference from DI: context does not give you constructor injection. Every component that calls useContext(SomeContext) re-renders whenever the context value changes. If you put everything in one context, a change to any piece of that data re-renders all consumers. Split contexts by update frequency — a ThemeContext that rarely changes is separate from AuthContext which may change more often.
useRef: Mutable Values and Direct DOM Access
useRef returns a mutable object with a .current property. Mutating .current does not trigger a re-render. This has two use cases:
1. Accessing DOM elements directly (equivalent to ElementReference in Blazor or getElementById in traditional JS):
function VideoPlayer({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
function play() {
videoRef.current?.play(); // Direct DOM access — equivalent to ElementReference.FocusAsync()
}
function pause() {
videoRef.current?.pause();
}
return (
<div>
<video ref={videoRef} src={src} />
<button onClick={play}>Play</button>
<button onClick={pause}>Pause</button>
</div>
);
}
2. Storing mutable values that should not trigger re-renders (equivalent to a private field that is not part of component state):
function SearchInput({ onSearch }: { onSearch: (query: string) => void }) {
const [inputValue, setInputValue] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const value = event.target.value;
setInputValue(value);
// Debounce — clear previous timer, set new one
// debounceRef.current is mutable without triggering a re-render
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
onSearch(value);
}, 300);
}
return (
<input
type="text"
value={inputValue}
onChange={handleChange}
placeholder="Search..."
/>
);
}
A common use of useRef in combination with useEffect is storing the previous value of a prop or state for comparison:
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}); // No dependency array — runs after every render
return ref.current; // Returns previous value (before current render)
}
useMemo and useCallback: Referential Stability
These two hooks are about performance and referential stability, not correctness. Understanding when to use them requires understanding why React re-renders.
When a component re-renders, every value defined in the function body is re-created. For primitives this is irrelevant — 5 === 5. For objects and functions this matters: {} !== {} and () => {} !== () => {}. If an object or function is passed as a prop or dependency, its new reference causes the child or effect to re-run even if the actual data has not changed.
useMemo memoizes a computed value. Use it when a computation is expensive, or when you need a stable object reference:
function OrderSummary({ orders }: { orders: Order[] }) {
// Without useMemo: this runs on every render
// With useMemo: only runs when orders changes
const totals = useMemo(() => {
return {
subtotal: orders.reduce((sum, o) => sum + o.amount, 0),
count: orders.length,
average: orders.length > 0
? orders.reduce((sum, o) => sum + o.amount, 0) / orders.length
: 0,
};
// Equivalent to a computed property backed by Lazy<T> with dependency tracking
}, [orders]); // Recalculate only when orders changes
return (
<div>
<p>Orders: {totals.count}</p>
<p>Subtotal: ${totals.subtotal.toFixed(2)}</p>
<p>Average: ${totals.average.toFixed(2)}</p>
</div>
);
}
useCallback memoizes a function reference. Use it when passing a callback to a child component that is optimized with React.memo, or when the function is a useEffect dependency:
function UserList({ onDelete }: { onDelete: (id: string) => Promise<void> }) {
const [users, setUsers] = useState<User[]>([]);
// Without useCallback: new function reference every render
// With useCallback: same reference when userId hasn't changed
const handleDelete = useCallback(
async (userId: string) => {
await onDelete(userId);
setUsers((prev) => prev.filter((u) => u.id !== userId));
},
[onDelete] // Only re-create if onDelete changes
);
return (
<ul>
{users.map((user) => (
<UserRow
key={user.id}
user={user}
onDelete={handleDelete}
/>
))}
</ul>
);
}
The honest guidance: do not reach for useMemo and useCallback by default. Add them when you can measure a real performance problem or when you have a useEffect that needs a stable function dependency. Premature memoization adds cognitive overhead without measurable benefit in most components.
Custom Hooks: Reusable Logic as Services
Custom hooks extract stateful logic into reusable functions — the closest equivalent to writing a small service class or using a mixin. Any function that calls other hooks is a custom hook; by convention its name starts with use.
// useFetch — equivalent to a generic IDataService<T>
// Encapsulates the loading/error/data pattern that would otherwise be repeated in every component
interface FetchState<T> {
data: T | null;
isLoading: boolean;
error: string | null;
refetch: () => void;
}
function useFetch<T>(url: string): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchTrigger, setRefetchTrigger] = useState(0);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json: T = await response.json();
if (!cancelled) setData(json);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Request failed");
}
} finally {
if (!cancelled) setIsLoading(false);
}
}
fetchData();
return () => { cancelled = true; };
}, [url, refetchTrigger]); // Re-fetch when URL changes or refetch() is called
const refetch = useCallback(() => {
setRefetchTrigger((n) => n + 1);
}, []);
return { data, isLoading, error, refetch };
}
// A more focused custom hook — useLocalStorage
// Equivalent to a service that wraps a storage mechanism
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
function setValue(value: T) {
try {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
console.error("useLocalStorage write failed:", err);
}
}
return [storedValue, setValue];
}
// Usage
function SettingsPanel() {
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
Switch to {theme === "light" ? "dark" : "light"} mode
</button>
);
}
// Consuming the useFetch hook — clean component, no lifecycle boilerplate
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error, refetch } = useFetch<User>(`/api/users/${userId}`);
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage message={error} onRetry={refetch} />;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<button onClick={refetch}>Refresh</button>
</div>
);
}
Custom hooks compose the same way utility classes compose in .NET. A useOrderForm hook might call useFetch, useLocalStorage, and useState internally. The consuming component never sees the implementation details.
When to Reach for a State Management Library
useState and useContext cover most needs. Reach for an external state management library when:
- State needs to be shared across many components that are not in a natural parent-child relationship
- You need time-travel debugging or state snapshots
- State updates involve complex business logic that benefits from testable reducers
- You are building an application where global state changes frequently and performance is a concern
The current landscape:
| Library | .NET analog | Best for |
|---|---|---|
| Zustand | A thread-safe singleton service with events | Simple global state — low boilerplate |
| Redux Toolkit | A full CQRS/Event Sourcing setup | Complex state machines, enterprise apps |
| Jotai / Recoil | Observable properties with fine-grained reactivity | Granular subscriptions, avoiding over-rendering |
| React Query / TanStack Query | A smart HttpClient + IMemoryCache combined | Server state (fetching, caching, syncing) |
TanStack Query deserves special mention: if your state management problem is primarily “fetch data, cache it, keep it fresh,” TanStack Query solves it more completely than anything you can build with useEffect. It handles loading states, error states, cache invalidation, background refetching, and deduplication. The useFetch hook in the example above is a simplified version of what TanStack Query provides out of the box.
Key Differences
| Concept | Blazor (.NET) | React Hooks |
|---|---|---|
| State declaration | private T _field + StateHasChanged() | const [value, setValue] = useState<T>(initial) |
| Initialization (once) | OnInitializedAsync() override | useEffect(() => { ... }, []) |
| Prop change response | OnParametersSetAsync() override | useEffect(() => { ... }, [prop]) |
| Cleanup / disposal | IDisposable.Dispose() | Return function from useEffect callback |
| Service/dependency access | @inject / constructor | useContext(SomeContext) |
| Mutable non-state field | Private field (no special syntax) | useRef — ref.current is mutable |
| Derived/computed values | C# computed property (get { return ... }) | useMemo(() => compute(), [deps]) |
| Stable callback reference | Func<T> field (allocated once) | useCallback(() => fn(), [deps]) |
| Reusable stateful logic | Service class injected via DI | Custom hook (function prefixed with use) |
| Global shared state | DI container (singleton service) | useContext + Provider, or Zustand/Redux |
| Conditional lifecycle | Override method with condition inside | Hook called unconditionally, condition inside |
Gotchas for .NET Engineers
Gotcha 1: Stale closures — the most common and most confusing React bug.
When a function defined inside a component captures a state value, it captures the value at the time the function was created. If state updates and the function is not re-created, it reads the old value. This is a JavaScript closure, not a React-specific concept — but React’s hook model makes it especially common.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
// STALE CLOSURE: count is always 0 here
// because this function captured count=0 at mount time
// and the empty dependency array means the effect never re-runs
console.log("count is:", count); // Always logs 0
setCount(count + 1); // Always sets to 0+1=1, not incrementing
}, 1000);
return () => clearInterval(interval);
}, []); // <-- The problem: count is a dependency, but omitted
return <p>{count}</p>;
}
There are two correct solutions:
// Solution A: Add count to the dependency array
// (re-creates the interval every time count changes — works but not ideal for intervals)
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1); // Now reads current count
}, 1000);
return () => clearInterval(interval);
}, [count]);
// Solution B: Use the functional update form — preferred for intervals/counters
// The setter's callback always receives the current state value
useEffect(() => {
const interval = setInterval(() => {
setCount((prev) => prev + 1); // prev is always current — no closure issue
}, 1000);
return () => clearInterval(interval);
}, []); // Now correctly empty: no dependencies
The rule: if your useEffect uses a value from component scope (props, state, other variables), that value must be in the dependency array unless you are using the functional update form to avoid needing it.
Gotcha 2: Infinite re-render loops from useEffect dependencies.
If you create an object or array inside a component and pass it as a useEffect dependency, you get an infinite loop. The object is re-created on every render, the effect sees a “changed” dependency, runs and updates state, which triggers a render, which creates a new object…
// WRONG — infinite loop
function UserDashboard({ userId }: { userId: string }) {
const options = { userId, timestamp: Date.now() }; // New object every render
useEffect(() => {
fetchDashboard(options); // options changes every render -> infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]); // <- options is referentially new every render
}
// CORRECT — depend on primitives, not objects
function UserDashboard({ userId }: { userId: string }) {
useEffect(() => {
fetchDashboard({ userId, timestamp: Date.now() }); // Object created inside effect
}, [userId]); // Primitive dependency — only re-runs when userId string changes
}
The fix is almost always: depend on primitives (strings, numbers, booleans), move object creation inside the effect, or stabilize the object with useMemo.
Gotcha 3: useEffect is not a lifecycle method — it is a synchronization mechanism.
.NET engineers tend to reach for useEffect for any logic that “runs when something happens.” The better mental model is: useEffect synchronizes your component with something external (an API, a subscription, a DOM measurement) whenever its dependencies change. It is not a general event handler.
Specifically: do not use useEffect to synchronize state with other state. If you need to compute B from A, compute it during render, not in an effect.
// WRONG — using useEffect to derive state from state
// Creates a render → effect → setState → render loop
function FullName({ firstName, lastName }: FullNameProps) {
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
return <p>{fullName}</p>;
}
// CORRECT — compute during render
function FullName({ firstName, lastName }: FullNameProps) {
const fullName = `${firstName} ${lastName}`; // Derived value, no hook needed
return <p>{fullName}</p>;
}
A useful heuristic: if your useEffect contains only setState calls and no async operations, subscriptions, or external interactions, you almost certainly do not need useEffect.
Gotcha 4: State updates are asynchronous and batched — do not read state immediately after setting it.
setState schedules a re-render; it does not mutate the current value immediately. Reading the state variable on the next line gives you the old value.
// WRONG — reads stale value
function Form() {
const [name, setName] = useState("");
function handleSubmit() {
setName("Alice");
console.log(name); // Logs "" — the state has not updated yet
submitToApi(name); // Submits "" — wrong
}
}
// CORRECT — use the value you set, not the state variable
function Form() {
const [name, setName] = useState("");
function handleSubmit() {
const newName = "Alice";
setName(newName);
submitToApi(newName); // Uses the value directly — correct
}
}
This is not like C#’s INotifyPropertyChanged where a property write is synchronous. The state update is a request to React to re-render with the new value.
Gotcha 5: Missing useEffect cleanup causes memory leaks and race conditions.
If your effect creates a subscription or starts an async operation, it must clean up. Without cleanup, the component can attempt to update state after it has unmounted, producing React’s “Can’t perform a React state update on an unmounted component” warning — and in some cases, actual memory leaks.
// WRONG — no cleanup
useEffect(() => {
const subscription = eventBus.subscribe("user-updated", handleUpdate);
// Component unmounts, but subscription lives on indefinitely
}, []);
// CORRECT — cleanup function returned
useEffect(() => {
const subscription = eventBus.subscribe("user-updated", handleUpdate);
return () => {
subscription.unsubscribe(); // Called on unmount and before next effect run
};
}, []);
For async operations, use the cancellation flag pattern shown in the useFetch example above. There is no native CancellationToken in the browser — the manual boolean flag is the equivalent.
Gotcha 6: useContext re-renders all consumers on any context value change.
If you put multiple unrelated values in one context, any change to any value re-renders every consumer. Split contexts by update domain, or use a state management library for frequently-changing global state.
// WRONG — one context for everything
// A user.name change re-renders every component consuming this context,
// including components that only care about the theme
const AppContext = React.createContext<{
user: User;
theme: Theme;
notifications: Notification[];
// ...
}>(null!);
// CORRECT — separate contexts by update frequency
const UserContext = React.createContext<User | null>(null);
const ThemeContext = React.createContext<Theme>("light");
const NotificationsContext = React.createContext<Notification[]>([]);
Hands-On Exercise
Build a useDataTable custom hook that encapsulates the full state management for a paginated, sortable data table. The hook should:
- Accept a
fetchFn: (params: TableParams) => Promise<PagedResult<T>>and aninitialParamsargument. - Expose:
data,isLoading,error,currentPage,totalPages,sortColumn,sortDirection. - Expose actions:
setPage(n),setSort(column, direction),refresh(). - Use
useEffectto fetch data whenever page or sort changes. - Handle the stale-request problem (a slow earlier request should not overwrite results from a faster later request).
Then build a UserTable component that consumes the hook and renders a sortable, paginated table of users from a mock API. Wire a useDebounce custom hook to the search input so that fetch calls are debounced to 300ms.
This exercise forces you to compose multiple hooks, handle cleanup correctly, manage derived state without redundant effects, and separate presentation from data-fetching logic — the same separation you would achieve with a repository pattern in .NET.
Quick Reference
Hook Selection Guide
| Need | Hook |
|---|---|
| Local component state | useState |
| Side effects, data fetching, subscriptions | useEffect |
| Access shared/global data (no prop drilling) | useContext |
| Mutable value without re-render, DOM reference | useRef |
| Memoize expensive computed value | useMemo |
| Stabilize function reference across renders | useCallback |
| Reusable stateful logic | Custom hook (use prefix) |
| Complex state with many sub-values | useReducer (not covered here — equivalent to a Flux reducer) |
useEffect Dependency Array Rules
| Scenario | Array | Example |
|---|---|---|
| Run once on mount | [] | useEffect(() => { init(); }, []) |
| Run on mount + when x changes | [x] | useEffect(() => { fetch(x); }, [x]) |
| Run after every render | (omit array) | useEffect(() => { log(); }) |
| Cleanup on unmount | Return function from [] effect | return () => { cleanup(); } |
Stale Closure Checklist
When a useEffect or event handler is reading a stale value, check:
- Is the value used inside the effect listed in the dependency array?
- If the value is a function or object, is it stabilized with
useCallback/useMemo? - Can you use the functional update form (
setState(prev => ...)) to avoid needing the current value as a dependency?
.NET to React Hooks Map
| Blazor / .NET | React Hook | Notes |
|---|---|---|
private T _field + StateHasChanged() | useState<T> | Setter triggers re-render |
OnInitializedAsync() | useEffect(() => {}, []) | Empty array = once on mount |
OnParametersSetAsync() | useEffect(() => {}, [prop]) | Runs when prop changes |
OnAfterRender() | useEffect(() => {}) | No array = after every render |
IDisposable.Dispose() | return () => {} inside useEffect | Returned cleanup function |
@inject IService Service | useContext(ServiceContext) | Requires Provider ancestor |
| Private mutable field (not UI state) | useRef | .current mutation safe |
Computed property (get { return ... }) | Inline computation or useMemo | Prefer inline; use useMemo for expensive ops |
| Helper/utility class | Custom hook | Compose hooks, prefix with use |
| Singleton service | Context + Provider | Or Zustand for complex cases |
CancellationToken | Boolean flag in useEffect closure | let cancelled = false pattern |
Task.WhenAll | Promise.all([...]) | Inside useEffect async function |
Common Hook Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
| UI doesn’t update after state change | Mutated state directly | Use spread: setState({...prev, field: value}) |
useEffect runs on every render | Object/array dependency re-created each render | Depend on primitives; move object inside effect |
| Stale value in interval/callback | Missing dependency in array | Add dependency or use functional update form |
| State update after unmount warning | No cleanup on async effect | Use cancellation flag, return cleanup function |
| Infinite render loop | Effect updates state that is also a dependency | Derive value during render instead of using effect |
| Hook called conditionally | Hook inside if or loop | Move hook to top level, use condition inside |
Further Reading
- React Documentation — Escape Hatches — The official deep-dive on
useEffect,useRef, and when to reach for each. The “Synchronizing with Effects” and “You Might Not Need an Effect” articles are required reading. - React Documentation — Reusing Logic with Custom Hooks — Official guidance with practical examples.
- TanStack Query Documentation — If most of your
useEffectusage is data fetching, this library eliminates most of that code and handles caching, deduplication, and refetching correctly out of the box. - eslint-plugin-react-hooks — Install this in every project. It enforces the rules of hooks and catches missing
useEffectdependencies at lint time, before they become runtime bugs.