Skip to content

Nice way to abstract your data fetching logic in React with TypeScript #2

@gabrielviol

Description

@gabrielviol

How to normalize, transform and separate DTO and App interfaces in order to create a better Developer Experience.

Most web apps rely on some sort of server-side interaction and REST is by far the most widely used architecture for that purpose. However, it’s common to receive bloated responses from REST API calls with too much data or, on the contrary, too little.

Some samples of bloated API responses may be for example these:

I say bloated because we may not need all the fields available in the response. Besides, we may want to change the casing of the fields to follow the patterns already in place in our app or compute new fields on the flow or add new defaults or even grab new data from other endpoints to complement the current one.

There are multiple ways to do it in React but I’ll propose here a folder/file structure with a specific logic that seems to deal well with these cases. The steps are these:

  • Isolate the fetching logic into a service folder
  • Add a layer of abstraction between the API response and the App
  • Return function response as an object
  • Correctly assert the TS typings with Discriminated Union

So let’s say we want to create a function to get pokemon details by its ID. Let’s first create a folder services/pokemon with the following structure:

|
|-services
|--pokemon
|---api.ts
|---utils.ts
|---typings.ts
|---getPokemonDetails.ts
|---index.ts
|

/services: folder containing all data fetching logic of the application
/pokemon: folder with all fetching logic related to pokemon
/api.ts: file with the api instance for pokemon related fetching logic
/utils.ts: file with utility functions for pokemon related fetching logic
/getPokemonDetails.ts: file with logic to fetch a pokemon details
/index.ts: barrel file containing all exports
/typings.ts: common interfaces used across different pokemon related functions

I did this separation because if you have more services, then it’s easier to manage but for simplicity, I’ll add everything to the same file.

For the api.ts file, you could use Axios to create an instance and set up interceptors.

Here’s the fun part. For the getPokemonDetails.ts file I propose to do it like this:

// for sake of simplicity, I'm adding all things in this file but 
// I recommend spliting them into their respective files as suggested

const url = 'https://pokeapi.co/api/v2'

const getIdFromUrl = (url: string) => {
  const splitted = url.split("/");
  const id = +splitted[splitted.length - 2];
  return id;
};

interface PokemonDetailsDTO {
  ... // used to type your API response
}

// used to type the interface used on your app
export interface PokemonDetails {
  id: number;
  name: string;
  height: number;
  types: {
    id: number;
    name: string;
  }[];
}

type GetPokemonDetailsSuccessResponse = {
  isOk: true;
  data: PokemonDetails;
  error: null;
};

type GetPokemonDetailsErrorResponse = {
  isOk: false;
  data: null;
  error: string;
};

type GetPokemonDetailsResponse =
  | GetPokemonDetailsSuccessResponse
  | GetPokemonDetailsErrorResponse;

export const getPokemonDetails = async (
  id: number
): Promise<GetPokemonDetailsResponse> => {
  try {
    const resp = await fetch(`${url}/${id}`);
    const data: PokemonDetailsDTO = await resp.json();

    // transform data
    const transformedData: PokemonDetails = {
      id: data.id,
      name: data.name,
      height: data.height,
      types: data.types.map((type) => ({
        id: getIdFromUrl(type.type.url),
        name: type.type.name,
      })),
    };

    return {
      isOk: true,
      data: transformedData,
      error: null,
    };
  } catch (e) {
    return {
      isOk: false,
      data: null,
      error: (e as Error).message,
    };
  }
};


// Usage:
const { isOk, data, error } = await getPokemonDetails(id)
if (isOk) console.log(data) // type: data=PokemonDetails, error=null
else console.log(error) //  type: data=null, error=string

Notice how I’m transforming the data. That’s related to what I said about bloated responses. Pokemon api returns too much data but for this App, I just need what’s declared in the interface PokemonDetails. If you want to type your API response, which I highly suggest, I propose using the terminology PokemonDetailsDTO. DTO stands for Data Transfer Object and means the data that’s being transferred/received from the server.

Also, notice the utility function getIdFromUrl. This API response returns the Id but let’s suppose it just returned the URL. This utility function would be useful to create this calculated field and we would do this in this layer instead of inside the React component. We want to deliver the data to the component already baked so it doesn’t need to know anything about the logic or possibly mismatched defaults.

Lastly, notice how the function response is returned as an object. I find this pattern pretty clean. You don’t need to write try/catch blocks inside of your React components and you can easily get the right data typing with the help of these handy typescript patterns.

You can also build a custom hook to isolate the logic from your React Component like so and you can add SWR or ReactQuery on top of that for Cache management.

import { useEffect, useState } from "react";
import { getPokemonDetails, PokemonDetails } from "services/pokemon";

export const usePokemon = (id: PokemonDetails["id"]) => {
  const [data, setData] = useState<PokemonDetails>();
  const [error, setError] = useState<string>();

  useEffect(() => {
    (async () => {
      const { isOk, data, error } = await getPokemonDetails(id);
      if (isOk) setData(data);
      else setError(error);
    })();
  }, [id]);

  return {
    pokemon: data,
    error,
    loading: !data && !error,
  };
};

That’s all. Hope this pattern makes sense to you and let me know if you have any feedback on it. Thanks!

Link for article: Medium

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions