UX Utilities and API Client

Building Better UX: Toast Notifications and API Clients

Welcome back! You’ve already built a working dashboard and tasks list that fetch and display data from your backend. You’ve structured your app with layouts and reusable components.

Now we’ll enhance your app’s user experience (UX) and developer experience (DX) by adding two essential utilities:

  • Toast notifications – small, non-intrusive messages that inform users when an action succeeds, fails, or needs attention.
  • Reusable API client – a central helper that standardizes how your app communicates with the backend.

Both of these are critical in professional web apps: toasts make your app feel interactive and friendly, while a shared API client keeps your code consistent, clean, and error-free.

To make the lesson easier to follow, we’ll treat this unit as two mini-sections:

  1. Toast Notifications with React Context — global user feedback from anywhere in the dashboard.
  2. Reusable API Client and Response Envelopes — consistent request helpers for the course backend.

Quick Recap: Where We Are

Quick Recap: Where We Are

Before we jump into new concepts, let’s step back and see what you’ve built so far:

FeatureWhat It DoesKey Files
LayoutsProvide consistent structure (header, sidebar, navigation).src/app/layout.tsx, src/app/(dashboard)/layout.tsx
Dashboard StatsFetches data and shows total, completed, and incomplete tasks.src/app/(dashboard)/page.tsx
Tasks ListDisplays each task with title, status, and due date.src/app/(dashboard)/tasks/page.tsx
Reusable ComponentsShared UI components like Button and TaskRow.src/components/ui/Button.tsx, src/components/tasks/TaskRow.tsx

So far, the user can view data, but they don’t get feedback when they perform actions (like creating or failing to create a task).
We’ll fix that now with toasts and a centralized API helper.

Toast Notifications: Giving Users Feedback

A toast is a temporary notification that appears on the screen — usually in a corner — to inform the user of an event.
They’re lightweight, don’t interrupt the user’s workflow, and automatically disappear after a few seconds.

Typical examples:

✅ “Task saved successfully!”
❌ “Failed to connect to the server.”
ℹ️ “Changes will be autosaved.”

The Toast Implementation

Let’s look at how the toast system is built in your project:

// src/components/ui/Toast.tsx
'use client';
import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';

export type Toast = { id: number; message: string; type: 'success' | 'error' | 'info' };

type ToastCtx = {
  success: (msg: string) => void;
  error: (msg: string) => void;
  info: (msg: string) => void;
};

const Ctx = createContext<ToastCtx | null>(null);

Step 1: What is React Context and Why Use It?

React Context is a way to share data between components without passing props manually down multiple layers.

In your case:

  • You want any component (like buttons, forms, or modals) to show a toast.
  • Instead of passing a showToast() function everywhere, you use Context.
  • The Context Provider stores and provides this functionality globally.
  • The useToast() hook lets any component access it easily.

So, Ctx here holds the functions success, error, and info that all components can use — as long as they are wrapped by the ToasterProvider.

Think of Context like a global state manager, but scoped to what you need (in this case, toast behavior).

Step 2: The Toaster Provider

export function ToasterProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<Toast[]>([]);

ToasterProvider wraps your app and manages all the active toast messages.

  • The toasts state holds an array of { id, message, type } objects — each representing a toast.
  • Every time you add a toast, you push a new object into that array; when it expires, you remove it.

Step 3: The push Function and useCallback

  const push = useCallback((message: string, type: Toast['type']) => {
    const id = Date.now() + Math.random();
    setToasts((t) => [...t, { id, message, type }]);
    setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2500);
  }, []);

Here’s what’s happening:

  • push() adds a new toast with a unique ID and automatically removes it after 2.5 seconds.
  • setToasts() uses the state updater function form (setToasts((t) => [...t, ...])) to ensure it works with the latest state.

Now, let’s understand why we wrap it with useCallback.

useCallback: What It Does and Why It’s Important

useCallback(fn, deps) returns a memoized version of a function that only changes if the dependencies change.

For this course, you only need to know that useCallback keeps the push function reference stable so the provider value does not change unnecessarily.

Why this matters: stable functions help Context consumers avoid extra re-renders. You’ll see deeper memoization patterns later.

Step 4: Creating the Context Value with useMemo

  const value = useMemo<ToastCtx>(
    () => ({
      success: (m) => push(m, 'success'),
      error: (m) => push(m, 'error'),
      info: (m) => push(m, 'info'),
    }),
    [push]
  );

This defines the actual API that child components will use.

Each function (success, error, info) calls push() with a different toast type.
We use useMemo to cache the value object so it doesn’t get recreated on every render.

useMemo: What It Does and Why It’s Important

useMemo(factory, deps) returns a memoized value (the result of running factory) that only changes when dependencies do.

Without it, this value object would be rebuilt every render, causing re-renders in all components using the context.

💡 In short:

  • useMemo = memoizes values or objects.
  • Prevents unnecessary re-renders by keeping stable references.
  • useCallback is the same idea but for functions.

Difference Summary:

HookMemoizesTypical Use
useCallback(fn, deps)A functionWhen passing event handlers to children or storing in context
useMemo(factory, deps)A computed value/objectWhen expensive calculations or objects shouldn’t be recreated every render

By combining useCallback and useMemo, you ensure that the toast system stays efficient, with no wasted re-renders across your entire app.

Step 5: Rendering the Toasts

  return (
    <Ctx.Provider value={value}>
      {children}
      <div className="fixed top-4 right-4 space-y-2 z-50">
        {toasts.map((t) => (
          <div
            key={t.id}
            className={`rounded-md px-4 py-2 text-sm shadow ${
              t.type === 'success'
                ? 'bg-green-600 text-white'
                : t.type === 'error'
                ? 'bg-red-600 text-white'
                : 'bg-gray-900 text-white'
            }`}
          >
            {t.message}
          </div>
        ))}
      </div>
    </Ctx.Provider>
  );
}
  • Ctx.Provider makes the value available to all child components.
  • The <div> renders all current toasts in a stack (space-y-2) in the top-right corner of the screen.
  • Each toast’s color depends on its type: green, red, or gray.
  • Because of the timer inside push(), toasts remove themselves automatically after 2.5 seconds.

Step 6: The useToast Hook

export function useToast() {
  const ctx = useContext(Ctx);
  if (!ctx) throw new Error('useToast must be used within ToasterProvider');
  return ctx;
}

This is a custom hook that:

  • Retrieves the toast context with useContext(Ctx).
  • Throws a clear error if the provider isn’t wrapping the component tree (a helpful safeguard).

Now, any component can call: const toast = useToast(); and then use toast.success(), toast.error(), or toast.info() anywhere in the app.

Example: Triggering Toasts

function Demo() {
  const toast = useToast();

  return (
    <div>
      <button onClick={() => toast.success('Task completed!')}>Show Success</button>
      <button onClick={() => toast.error('Failed to load tasks!')}>Show Error</button>
      <button onClick={() => toast.info('Data refreshed.')}>Show Info</button>
    </div>
  );
}

You can trigger these messages in real API call handlers — for example, after creating, updating, or deleting a task.

The Reusable API Client

Now that you can show toasts for feedback, let’s look at how your app communicates with the backend.

Without an API client, you’d have to write repetitive code like this everywhere:

const res = await fetch('/api/tasks');
const data = await res.json();

That’s fine once or twice, but across many files it becomes messy.
Instead, we create a single reusable client that wraps all this logic.

The Full API Client

// src/lib/apiClient.ts
export type ApiSuccess<T> = { data: T; meta: any };
export type ApiError = { error: any; meta: any };

async function request<T>(url: string, init?: RequestInit): Promise<T | ApiError> {
  const res = await fetch(url, init);
  const text = await res.text();
  let json: any = {};
  try {
    json = text ? JSON.parse(text) : {};
  } catch {
    return { error: 'Invalid JSON response', meta: {} } as ApiError;
  }
  if ('error' in json) return json as ApiError;
  if ('data' in json) return (json as ApiSuccess<T>).data;
  return json as T;
}

How It Works

  • Wraps fetch with consistent JSON parsing.
  • Interprets the course backend’s standardized response envelope — either { data } or { error }.
  • This simplified client assumes failed requests return the course backend’s { error } envelope; it is not a complete general-purpose HTTP error handler.
  • Ensures every request in your app behaves the same way.

Standardized HTTP Methods

export const api = {
  get:   <T>(url: string) => request<T>(url),
  post:  <T>(url: string, body?: any) =>
    request<T>(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
  put:   <T>(url: string, body?: any) =>
    request<T>(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
  patch: <T>(url: string, body?: any) =>
    request<T>(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
  del:   <T>(url: string) => request<T>(url, { method: 'DELETE' }),
};

Each function:

  • Defines the HTTP method (GET, POST, PUT, PATCH, DELETE).
  • Adds standard headers (like 'Content-Type': 'application/json').
  • Uses request() to parse and handle errors automatically.

This saves huge amounts of boilerplate, especially when used throughout your app.

Example: Combining the API Client with Toasts

import { api } from '@/lib/apiClient';
import { useToast } from '@/components/ui/Toast';

function CreateTaskButton() {
  const toast = useToast();

  async function handleClick() {
    const result = await api.post('/api/tasks', { title: 'New Task' });
    if ('error' in result) {
      toast.error('Failed to create task.');
    } else {
      toast.success('Task created successfully!');
    }
  }

  return <button onClick={handleClick}>Create Task</button>;
}
  • The API client sends the request.
  • If there’s an error, toast.error() gives the user immediate feedback.
  • If successful, toast.success() confirms it worked.

Summary & What’s Next

In this lesson, you learned:

  • How React Context provides shared functionality (like toast management) to your entire app.
  • How useCallback and useMemo improve performance by preventing unnecessary re-renders.
  • The difference between useCallback (for functions) and useMemo (for computed values).
  • How to design a ToasterProvider that manages, displays, and auto-cleans up toast messages.
  • How to build a reusable API client that handles requests consistently.
  • How to combine both tools to give users instant feedback when actions succeed or fail.

Next, you’ll apply these utilities throughout your project to make your task management app feel polished and responsive — just like a production-grade web application.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal