Skip to main content

🧱 Part 4 — State Management 101 (useState, useReducer & Custom Hooks) ⚡

 State is the memory of your components. In this part, we’ll:

  • Learn when to use useState vs useReducer.
  • Build typed reducers.
  • Create custom hooks for reusable state logic.

🎯 1. Recap: useState

You already know useState holds a single value:

import { useState } from 'react';

export function Counter() {
  // count: number, setCount: function to update it
  const [count, setCount] = useState(0); // inferred as number

  return (
    <div>
      <p>Count: {count}</p>
      {/* onClick updates state */}
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

👉 Perfect for simple local state.


🎯 2. When to use useReducer

If state has multiple fields or complex updates, useReducer is better.

Example: a Task object.

// Define a Task type
type Task = { id: string; title: string; done: boolean };

// State is an array of tasks
type State = Task[];

// Define all possible actions
type Action =
  | { type: 'add'; title: string }
  | { type: 'toggle'; id: string }
  | { type: 'remove'; id: string };

// Reducer function: receives current state + action → returns new state
function tasksReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      // create new task with random id
      return [...state, { id: crypto.randomUUID(), title: action.title, done: false }];
    case 'toggle':
      // flip the "done" flag
      return state.map(t => t.id === action.id ? { ...t, done: !t.done } : t);
    case 'remove':
      // remove task by id
      return state.filter(t => t.id !== action.id);
    default: {
      // exhaustive check: ensures all Action types are handled
      const _exhaustive: never = action;
      return state;
    }
  }
}

Usage:

import { useReducer, useState } from 'react';

export function TaskList() {
  // tasks: State, dispatch: function to send actions
  const [tasks, dispatch] = useReducer(tasksReducer, []);
  const [title, setTitle] = useState('');

  const addTask = () => {
    if (title.trim()) {
      dispatch({ type: 'add', title }); // dispatch an "add" action
      setTitle('');
    }
  };

  return (
    <div>
      {/* controlled input */}
      <input value={title} onChange={e => setTitle(e.target.value)} />
      <button onClick={addTask}>Add</button>
      <ul>
        {tasks.map(t => (
          <li key={t.id}>
            <label>
              {/* toggle checkbox dispatches a "toggle" action */}
              <input
                type="checkbox"
                checked={t.done}
                onChange={() => dispatch({ type: 'toggle', id: t.id })}
              />
              {t.title}
            </label>
            {/* delete button dispatches a "remove" action */}
            <button onClick={() => dispatch({ type: 'remove', id: t.id })}>❌</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

👉 With useReducer, your state updates are predictable, and TypeScript ensures actions are always valid.


🎯 3. Extracting Logic with Custom Hooks

If multiple components need the same logic, extract it.

// src/hooks/useTasks.ts
import { useReducer } from 'react';

// Define Task type
type Task = { id: string; title: string; done: boolean };

// State is array of tasks
type State = Task[];

// All actions our reducer can handle
type Action =
  | { type: 'add'; title: string }
  | { type: 'toggle'; id: string }
  | { type: 'remove'; id: string };

// Reducer function
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      return [...state, { id: crypto.randomUUID(), title: action.title, done: false }];
    case 'toggle':
      return state.map(t => t.id === action.id ? { ...t, done: !t.done } : t);
    case 'remove':
      return state.filter(t => t.id !== action.id);
    default:
      return state; // fallback (shouldn’t happen with exhaustive typing)
  }
}

// Custom hook that encapsulates tasks logic
export function useTasks(initial: Task[] = []) {
  const [tasks, dispatch] = useReducer(reducer, initial);
  // return both tasks state and dispatcher to the caller
  return { tasks, dispatch };
}

Usage:

import { useTasks } from '@/hooks/useTasks';

export function TaskApp() {
  const { tasks, dispatch } = useTasks();

  return (
    <ul>
      {tasks.map(t => (
        <li key={t.id}>{t.title}</li>
      ))}
    </ul>
  );
}

👉 Now any component can use useTasks and get a fully typed task manager.


🎯 4. Visual Example for Blog Readers

Show before/after:

  • Before → scattered state logic in each component.
  • After → clean reducer + hook that centralizes state logic.

✅ Wrap-Up

In Part 4, you learned:

  • When to use useState vs useReducer.
  • How to create a typed reducer with exhaustive checks.
  • How to move logic into a custom hook for reusability.

👉 Next: We’ll explore Routing with React Router — setting up pages, navigation, and type-safe route parameters.

Comments

Popular posts from this blog

🌟 Dot net Microservices interview questions

Here is a comprehensive list of 200 .NET microservices coding questions covering all core microservices concepts and cross-cutting concerns relevant for designing, building, deploying, and maintaining .NET-based distributed systems. 🧩 A. Microservices Fundamentals (20) Build a microservice in .NET 8 that exposes a simple CRUD API. Implement communication between two microservices using REST. How would you design microservices for an e-commerce application? Create a microservice that handles user registration and login. How do you isolate domain logic in a microservice? How to apply the "Single Responsibility Principle" in microservices? Design a service registry/discovery mechanism using custom middleware. Implement a service that handles file uploads and metadata separately. Build a stateless microservice and explain its benefits. Implement health check endpoints in .NET 8. Demonstrate versioning in a microservice API. Add Swagger/OpenAPI support to your m...

⚡ Part 1: Introduction to Generics in C#

🌍 Why Do We Need Generics? Imagine you want to create a stack (like a pile of books 📚): You can push items on top You can pop items off the top If we write a stack for integers : public class IntStack { private int[] items = new int[10]; private int index = 0; public void Push(int item) => items[index++] = item; public int Pop() => items[--index]; } 👉 Problem: This only works for int . What if we want a string stack ? Or a Customer stack ? We’d have to duplicate code for every type. 😢 ✅ Solution: Generics Generics let us create type-safe reusable code without duplication. We can say: “I don’t care what type it is yet — I’ll decide later.” 1) Generic Classes Here’s a generic stack : // Generic class "Stack<T>" // The <T> is a placeholder for any type public class Stack<T> { private T[] items = new T[10]; // Array of type T private int index = 0; // Push adds an item of type T public void P...

🚪 Part 9: API Gateway for .NET 8 Microservices (Ocelot & YARP)

Once you have multiple microservices (Products, Orders, Payments…), exposing each one directly to clients gets messy: Different base URLs Duplicated auth logic No unified rate limiting / caching Hard to evolve routes or aggregate data 👉 Enter the API Gateway — your single front door for all microservices. An API Gateway handles: ✅ Routing & path rewriting ✅ Load balancing, retries, circuit breakers ✅ Authentication & Authorization (JWT, OAuth2) ✅ Rate limiting & caching ✅ Aggregation (compose results from multiple services) In this post we’ll implement two strong options: Ocelot → config-driven, mature, DevOps-friendly YARP (Yet Another Reverse Proxy) → Microsoft’s code-first, extensible gateway ⚖️ Ocelot vs YARP — When to Choose Ocelot → JSON config, minimal C#, built-in QoS (rate limit, circuit breaker). Perfect for teams that like DevOps config-as-code. YARP → full C# control, middleware-friendly, can embed into broader apps (e.g. add dashb...