Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 ArrayWhen 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 analogBest for
ZustandA thread-safe singleton service with eventsSimple global state — low boilerplate
Redux ToolkitA full CQRS/Event Sourcing setupComplex state machines, enterprise apps
Jotai / RecoilObservable properties with fine-grained reactivityGranular subscriptions, avoiding over-rendering
React Query / TanStack QueryA smart HttpClient + IMemoryCache combinedServer 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

ConceptBlazor (.NET)React Hooks
State declarationprivate T _field + StateHasChanged()const [value, setValue] = useState<T>(initial)
Initialization (once)OnInitializedAsync() overrideuseEffect(() => { ... }, [])
Prop change responseOnParametersSetAsync() overrideuseEffect(() => { ... }, [prop])
Cleanup / disposalIDisposable.Dispose()Return function from useEffect callback
Service/dependency access@inject / constructoruseContext(SomeContext)
Mutable non-state fieldPrivate field (no special syntax)useRefref.current is mutable
Derived/computed valuesC# computed property (get { return ... })useMemo(() => compute(), [deps])
Stable callback referenceFunc<T> field (allocated once)useCallback(() => fn(), [deps])
Reusable stateful logicService class injected via DICustom hook (function prefixed with use)
Global shared stateDI container (singleton service)useContext + Provider, or Zustand/Redux
Conditional lifecycleOverride method with condition insideHook 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:

  1. Accept a fetchFn: (params: TableParams) => Promise<PagedResult<T>> and an initialParams argument.
  2. Expose: data, isLoading, error, currentPage, totalPages, sortColumn, sortDirection.
  3. Expose actions: setPage(n), setSort(column, direction), refresh().
  4. Use useEffect to fetch data whenever page or sort changes.
  5. 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

NeedHook
Local component stateuseState
Side effects, data fetching, subscriptionsuseEffect
Access shared/global data (no prop drilling)useContext
Mutable value without re-render, DOM referenceuseRef
Memoize expensive computed valueuseMemo
Stabilize function reference across rendersuseCallback
Reusable stateful logicCustom hook (use prefix)
Complex state with many sub-valuesuseReducer (not covered here — equivalent to a Flux reducer)

useEffect Dependency Array Rules

ScenarioArrayExample
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 unmountReturn function from [] effectreturn () => { cleanup(); }

Stale Closure Checklist

When a useEffect or event handler is reading a stale value, check:

  1. Is the value used inside the effect listed in the dependency array?
  2. If the value is a function or object, is it stabilized with useCallback/useMemo?
  3. Can you use the functional update form (setState(prev => ...)) to avoid needing the current value as a dependency?

.NET to React Hooks Map

Blazor / .NETReact HookNotes
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 useEffectReturned cleanup function
@inject IService ServiceuseContext(ServiceContext)Requires Provider ancestor
Private mutable field (not UI state)useRef.current mutation safe
Computed property (get { return ... })Inline computation or useMemoPrefer inline; use useMemo for expensive ops
Helper/utility classCustom hookCompose hooks, prefix with use
Singleton serviceContext + ProviderOr Zustand for complex cases
CancellationTokenBoolean flag in useEffect closurelet cancelled = false pattern
Task.WhenAllPromise.all([...])Inside useEffect async function

Common Hook Errors and Fixes

ErrorCauseFix
UI doesn’t update after state changeMutated state directlyUse spread: setState({...prev, field: value})
useEffect runs on every renderObject/array dependency re-created each renderDepend on primitives; move object inside effect
Stale value in interval/callbackMissing dependency in arrayAdd dependency or use functional update form
State update after unmount warningNo cleanup on async effectUse cancellation flag, return cleanup function
Infinite render loopEffect updates state that is also a dependencyDerive value during render instead of using effect
Hook called conditionallyHook inside if or loopMove hook to top level, use condition inside

Further Reading