Skip to content

Part 2.1 — Setting Up HTTP Fetching in React with TypeScript

Site Console Site Console
4 min read Updated Nov 26, 2025 Web Development 0 comments
Setting Up HTTP Fetching in React with TypeScript

Most real applications become useful only after they talk to a server. Whether you’re loading a task list, creating users, or updating a profile, everything flows through HTTP requests.
In this post, we’ll set up clean, type-safe HTTP fetching patterns using React + TypeScript — the same patterns you’ll rely on when connecting to your NestJS backend.


1. Why Use Fetch (and Not Axios)?

React doesn’t require any special library for HTTP requests.
fetch is:

  • built into the browser

  • fully typed in TypeScript

  • Promise-based

  • flexible enough for REST patterns

Later, when we add authentication, pagination, or error handlers, fetch stays predictable.


2. A Minimal Fetch Example (the Right Way)

Let’s start with a simple component that loads users from a REST endpoint:

import { useEffect, useState } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

export function UsersList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState<boolean>(true);

  useEffect(() => {
    async function load() {
      const res = await fetch('/api/users');
      if (!res.ok) throw new Error('Failed to load users');
      const data = (await res.json()) as User[];
      setUsers(data);
      setLoading(false);
    }
    load();
  }, []);

  if (loading) return <p>Loading...</p>;
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

This is the simplest form of data fetching: load on mount, set state, render.

But we can do better.


3. Move Fetching Into a Reusable Function

The component shouldn’t know how your API works — just what it returns.
Let’s create a typed fetch utility.

// src/lib/http.ts
export async function http<T>(url: string, options?: RequestInit): Promise<T> {
  const res = await fetch(url, {
    headers: { 'Content-Type': 'application/json' },
    ...options,
  });

  if (!res.ok) {
    const message = await res.text();
    throw new Error(message || `HTTP error: ${res.status}`);
  }

  return res.json() as Promise<T>;
}

Now fetching users becomes:

import { http } from '../lib/http';
import type { User } from '../types/user';
useEffect(() => {
  http<User[]>('/api/users')
    .then(data => setUsers(data))
    .catch(console.error)
    .finally(() => setLoading(false));
}, []);

This will scale beautifully when you add API keys, JWT auth, or retry logic.


4. Building a Use-Users Hook (Cleaner UI)

Components should focus on markup, not side-effects.
Let’s create a custom hook for fetching users.

// src/hooks/useUsers.ts
import { useEffect, useState } from 'react';
import { http } from '../lib/http';
import type { User } from '../types/user';

export function useUsers() {
  const [data, setData] = useState<User[]>([]);
  const [loading, setLoading] = useState<boolean>(true);

  useEffect(() => {
    http<User[]>('/api/users')
      .then(setData)
      .finally(() => setLoading(false));
  }, []);

  return { data, loading };
}

Then the UI becomes trivial:

// Component
const { data: users, loading } = useUsers();

if (loading) return <p>Loading...</p>;
return users.map(u => <p key={u.id}>{u.name}</p>);

Small, readable, and testable.


5. Posting Data (Creating New Records)

Fetching isn’t just reads — you’ll soon do CREATE operations.
Let’s build a typed POST helper:

// src/api/users.ts
import { http } from '../lib/http';
import type { User } from '../types/user';

interface CreateUserDto {
  name: string;
  email: string;
}

export function createUser(dto: CreateUserDto) {
  return http<User>('/api/users', {
    method: 'POST',
    body: JSON.stringify(dto),
  });
}

Usage in a form handler:

async function handleSubmit() {
  try {
    const user = await createUser({ name, email });
    setUsers(prev => [...prev, user]);
  } catch (err) {
    console.error(err);
  }
}

Notice that frontend DTOs match backend DTOs — once we add NestJS controllers in Part 3, everything aligns.


6. Updating Data (PUT/PATCH)

Same pattern, different method:

export function updateUser(id: number, dto: Partial<User>) {
  return http<User>(`/api/users/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(dto),
  });
}

On the UI side, updating becomes natural:

await updateUser(user.id, { name: 'Updated Name' });

7. Deleting Data

REST is straightforward:

export function deleteUser(id: number) {
  return http<void>(`/api/users/${id}`, { method: 'DELETE' });
}

Your component simply filters out the deleted record.


8. Why Shared Types Matter

When React, NestJS, and Prisma all share a User definition (via /packages/types), you eliminate entire classes of bugs:

  • Mismatched field names

  • Outdated responses

  • Incorrect client expectations

Later, when the backend changes, TypeScript forces your frontend to update too.

That’s full-stack correctness with minimal friction.


9. Production Considerations (You’ll Use These Soon)

As your REST calls grow, you’ll want:

  • Cancellation for slow requests

  • Retry logic

  • Exponential backoff

  • Global error boundaries

  • JWT token injection

  • Request logging for debugging

We’ll weave these into upcoming parts once your NestJS API is live.


10. Summary

You now know how to:

  • Use fetch with TypeScript types

  • Build reusable HTTP utilities

  • Fetch and display data with custom React hooks

  • Handle POST, PATCH, and DELETE operations cleanly

  • Prepare your frontend for real REST integration

In Part 2.2, we’ll build complete CRUD interfaces and align them with API contracts — shaping the frontend around the schema your NestJS backend will expose.

Related

Leave a comment

Sign in to leave a comment.

Comments