DEV Community

Camilo Mejía
Camilo Mejía

Posted on

Fetch data with React Hooks and Typescript

Reusing logic in React has been complex, and patterns like HOCs and Render Props tried to solve that problem. With the recent addition of Hooks, reusing logic becomes easier. In this article, I will show a simple way to use the Hooks useEffect and useState to load data from a web service (I'm using swapi.co in the examples to load Star Wars starships) and how to manage the loading state easily. As a bonus, I'm using Typescript. I will build a simple app to buy and sell Star Wars starships, you can see the final result here https://camilosw.github.io/react-hooks-services

Loading initial data

Until the release of React Hooks, the simplest way to load initial data from a web service was on the componentDidMount:

class Starships extends React.Component {
  state = {
    starships: [],
    loading: true,
    error: false
  }

  componentDidMount () {
    fetch('https://swapi.co/api/starships')
      .then(response => response.json())
      .then(response => this.setState({ 
        starships: response.results,
        loading: false
      }))
      .catch(error => this.setState({ 
        loading: false, 
        error: true 
      }));
  }

  render () {
    const { starships, loading, error } = this.state;
    return (
      <div>
        {loading && <div>Loading...</div>}
        {!loading && !error && 
          starships.map(starship => (
            <div key={starship.name}>
              {starship.name}
            </div>
          ))
        }
        {error && <div>Error message</div>}
      </div>
    );
  }
};

But reusing that code is hard because you can't extract behavior from the component prior React 16.8. The popular choices are to use higher-order components or render props, but there are some downsides with those approaches as described on the React Hooks documentation https://reactjs.org/docs/hooks-intro.html#its-hard-to-reuse-stateful-logic-between-components

With Hooks, we can extract the behavior to a custom Hook so we can reuse it in any component easily. If you don't know how to create custom Hooks, read the docs first: https://reactjs.org/docs/hooks-custom.html.

Because we are using Typescript, first we need to define the shape of the data we expect to receive from the web service, so I defined the interface Starship:

export interface Starship {
  name: string;
  crew: string;
  passengers: string;
  cost_in_credits?: string;
  url: string;
}

And because we will be dealing with web services that have multiple states, I defined one interface per state. Finally, I defined Service as a union type of those interfaces:

interface ServiceInit {
  status: 'init';
}
interface ServiceLoading {
  status: 'loading';
}
interface ServiceLoaded<T> {
  status: 'loaded';
  payload: T;
}
interface ServiceError {
  status: 'error';
  error: Error;
}
export type Service<T> =
  | ServiceInit
  | ServiceLoading
  | ServiceLoaded<T>
  | ServiceError;

ServiceInit and ServiceLoading define the state of the web service before any action and while loading respectively. ServiceLoaded has the property payload to store the data loaded from the web service (note that I'm using a generic here, so I can use that interface with any data type for the payload). ServiceError has the property error to store any error that may occur. With this union type, if we set the string 'loading' in the status property and try to assign something to payload or error properties, Typescript will fail, because we didn't define an interface that allows a status of type 'loading' alongside a property named payload or error. Without Typescript or any other type checking, your code will only fail at runtime if you make that mistake.

With the type Service and the interface Starship defined, now we can create the custom Hook usePostStarshipService:

import { useEffect, useState } from 'react';
import { Service } from '../types/Service';
import { Starship } from '../types/Starship';

export interface Starships {
  results: Starship[];
}

const usePostStarshipService = () => {
  const [result, setResult] = useState<Service<Starships>>({
    status: 'loading'
  });

  useEffect(() => {
    fetch('https://swapi.co/api/starships')
      .then(response => response.json())
      .then(response => setResult({ status: 'loaded', payload: response }))
      .catch(error => setResult({ status: 'error', error }));
  }, []);

  return result;
};

export default usePostStarshipService;

This is what happens in the previous code:

  • Because SWAPI returns an array of starships inside the array results, I defined a new interface Starships that contains the property results as an array of Starship.
  • The custom Hook usePostStarshipService is simply a function, starting with the word use as recommended on the React Hooks documentation: https://reactjs.org/docs/hooks-custom.html#extracting-a-custom-hook.
  • Inside that function, I'm using the Hook useState to manage the web service states. Note that I need to define the exact type of data that will be managed by the result state passing the generic <Service<Starship>>. I'm initializing the Hook with the interface ServiceInit of the union type Service, so the only property allowed is status with the string 'loading'.
  • I'm also using the Hook useEffect with a callback as the first argument to fetch the data from the web service, and an empty array as the second argument. That second argument tells useEffect what is the condition to run the callback, and because we are passing an empty array, the callback will be called only once (read more about useEffect if you are not familiarized with that Hook https://reactjs.org/docs/hooks-effect.html).
  • Finally, I'm returning result. That object contains the state and any payload or error as a result of calling the web service. That's what we need in our component to show the status of the web service to the user and the data retrieved.

Note that the way I used fetch in the previous example is very simple but not enough for production code. For example, the catch will only capture network errors, not 4xx or 5xx errors. In your own code, it's better to create another function that wraps fetch for handling errors, headers, etc.

Now, we can use our Hook to retrieve the starships list and show them to the user:

import React from 'react';
import useStarshipsService from '../services/useStarshipsService';

const Starships: React.FC<{}> = () => {
  const service = useStarshipsService();

  return (
    <div>
      {service.status === 'loading' && <div>Loading...</div>}
      {service.status === 'loaded' &&
        service.payload.results.map(starship => (
          <div key={starship.url}>{starship.name}</div>
        ))}
      {service.status === 'error' && (
        <div>Error, the backend moved to the dark side.</div>
      )}
    </div>
  );
};

export default Starships;

This time, our custom Hook useStarshipService will manage the status, so we only need to render conditionally based on the status property of the returned service object.

Note that if you try to access payload when status is 'loading', TypeScript will fail, because payload only exists in the ServiceLoaded interface, not in the ServiceLoading one:

TypeScript is enough smart to know that if the comparison between the status property and the string 'loading' is true, the corresponding interface is ServiceLoaded and in that circumstance the starships object doesn't have a payload property.

Loading content on state change

In our example, if the user clicks on any starship, we change the state on our component to set the selected starship and call the web service with the url corresponding to that ship (note that https://swapi.co/api/starships loads all the data of every starship, so there is no need to load that data again. I'm doing that only for demonstration purposes.)

Traditionally we used componentDidUpdate to detect state changes and do something in consequence:

class Starship extends React.Component {
  ...

  componentDidUpdate(prevProps) {
    if (prevProps.starship.url !== this.props.starship.url) {
      fetch(this.props.starship.url)
        .then(response => response.json())
        .then(response => this.setState({
          starship: response,
          loading: false
        }))
        .catch(error => this.setState({ 
          loading: false, 
          error: true 
        }));
    }
  }

  ...
};

If we need to make different actions when different props and state properties change, componentDidUpdate quickly becomes a mess. With Hooks we can encapsulate that actions in separated custom Hooks. In this case, we'll create a custom Hook to extract the behavior inside componentDidUpdate as we did previously:

import { useEffect, useState } from 'react';
import { Service } from '../types/Service';
import { Starship } from '../types/Starship';

const useStarshipByUrlService = (url: string) => {
  const [result, setResult] = useState<Service<Starship>>({
    status: 'loading'
  });

  useEffect(() => {
    if (url) {
      setResult({ status: 'loading' });
      fetch(url)
        .then(response => response.json())
        .then(response => setResult({ status: 'loaded', payload: response }))
        .catch(error => setResult({ status: 'error', error }));
    }
  }, [url]);

  return result;
};

export default useStarshipByUrlService;

This time, our custom Hook receives the url as a parameter, and we use that as the second argument of the Hook useEffect. That way, every time the url change, the callback inside useEffect will be called retrieving the data for the new starship.

Note that inside the callback, I'm calling setResult to set status as 'loading'. That's because the callback will be called multiple times, so we need to reset the status before start fetching.

In our Starship component, we receive the url as a prop and pass it to our custom Hook useStarshipByUrlService. Every time the url change in the parent component, our custom Hook will call again the web service and will manage the status for us:

import React from 'react';
import useStarshipByUrlService from '../services/useStarshipByUrlService';

export interface Props {
  url: string;
}

const Starship: React.FC<Props> = ({ url }) => {
  const service = useStarshipByUrlService(url);

  return (
    <div>
      {service.status === 'loading' && <div>Loading...</div>}
      {service.status === 'loaded' && (
        <div>
          <h2>{service.payload.name}</h2>
          ...
        </div>
      )}
      {service.status === 'error' && <div>Error message</div>}
    </div>
  );
};

export default Starship;

Sending content

Sending content seems similar to loading content when state change. In the first case we passed a url to our custom Hook and now we could pass an object with the data to be sent. If we try to do the same, the code will be something like this:

const usePostStarshipService = (starship: Starship) => {
  const [result, setResult] = useState<Service<Starship>>({
    status: 'init'
  });

  useEffect(() => {
    setResult({ status: 'loading' });
    fetch('https://swapi.co/api/starships', {
      method: 'POST',
      body: JSON.stringify(starship)
    })
      .then(response => response.json())
      .then(response => {
        setResult({ status: 'loaded', payload: response });
      })
      .catch(error => {
        setResult({ status: 'error', error });
      });
  }, [starship]);

  return result;
};

const CreateStarship: React.FC<{}> = () => {
  const initialStarshipState: Starship = {
    name: '',
    crew: '',
    passengers: '',
    cost_in_credits: ''
  };
  const [starship, setStarship] = useState<PostStarship>(initialStarshipState);
  const [submit, setSubmit] = useState(false);
  const service = usePostStarshipService(starship);

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    event.persist();
    setStarship(prevStarship => ({
      ...prevStarship,
      [event.target.name]: event.target.value
    }));
  };

  const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setSubmit(true);
  };

  useEffect(() => {
    if (submit && service.status === 'loaded') {
      setSubmit(false);
      setStarship(initialStarshipState);
    }
  }, [submit]);

  return (
    <form onSubmit={handleFormSubmit}>
      <input 
        type="text" 
        name="name" 
        value={starship.name} 
        onChange={handleChange}
      />
      ...
    </form>
  )
}

But there are some problems on the previous code:

  • We passed the starship object to our custom Hook and we passed that object as the second argument of the useEffect Hook. Because the onChange handler will change the starship object on every keystroke, our web service will be called every time the user type.
  • We need to use the Hook useState to create the boolean state submit only to know when we can clean the form. We could use this boolean as the second parameter of usePostStarshipService to solve the previous problem, but we'd be complicating our code.
  • The boolean state submit added logic to our component that must be replicated on other components that reuse our custom Hook usePostStarshipService.

There is a better way, this time without the useEffect Hook:

import { useState } from 'react';
import { Service } from '../types/Service';
import { Starship } from '../types/Starship';

export type PostStarship = Pick<
  Starship,
  'name' | 'crew' | 'passengers' | 'cost_in_credits'
>;

const usePostStarshipService = () => {
  const [service, setService] = useState<Service<PostStarship>>({
    status: 'init'
  });

  const publishStarship = (starship: PostStarship) => {
    setService({ status: 'loading' });

    const headers = new Headers();
    headers.append('Content-Type', 'application/json; charset=utf-8');

    return new Promise((resolve, reject) => {
      fetch('https://swapi.co/api/starships', {
        method: 'POST',
        body: JSON.stringify(starship),
        headers
      })
        .then(response => response.json())
        .then(response => {
          setService({ status: 'loaded', payload: response });
          resolve(response);
        })
        .catch(error => {
          setService({ status: 'error', error });
          reject(error);
        });
    });
  };

  return {
    service,
    publishStarship
  };
};

export default usePostStarshipService;

First, we created a new PostStarship type derived from Starship, picking the properties that will be sent to the web service. Inside our custom Hook, we initialized the service with the string 'init' in the property status because usePostStarshipService will do nothing with the web service when called. Instead of the useEffect Hook, this time we created a function that will receive the form data to be sent to the web service and will return a Promise. Finally, we return an object with the service object and the function in charge to call the web service.

Note: I could have returned an array instead of an object in our custom Hook to behave like the useState Hook, that way the names in the component could be named arbitrarily. I decided to return an object instead because I think there is no need to rename them. You are free to return an array instead if you prefer.

Our CreateStarship component will be simpler this time:

import React, { useState } from 'react';
import usePostStarshipService, {
  PostStarship
} from '../services/usePostStarshipService';
import Loader from './Loader';

const CreateStarship: React.FC<{}> = () => {
  const initialStarshipState: PostStarship = {
    name: '',
    crew: '',
    passengers: '',
    cost_in_credits: ''
  };
  const [starship, setStarship] = useState<PostStarship>(
    initialStarshipState
  );
  const { service, publishStarship } = usePostStarshipService();

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    event.persist();
    setStarship(prevStarship => ({
      ...prevStarship,
      [event.target.name]: event.target.value
    }));
  };

  const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    publishStarship(starship).then(() => setStarship(initialStarshipState));
  };

  return (
    <div>
      <form onSubmit={handleFormSubmit}>
        <input
          type="text"
          name="name"
          value={starship.name}
          onChange={handleChange}
        />
        ...
      </form>
      {service.status === 'loading' && <div>Sending...</div>}
      {service.status === 'loaded' && <div>Starship submitted</div>}
      {service.status === 'error' && <div>Error message</div>}
    </div>
  );
};

export default CreateStarship;

I'm using the useState Hook to manage the state of the form but handleChange behaves as when we use this.state in class components. Our usePostStarshipService does nothing other than returning our service object in an initial state and returning the publishStarship method to call the web service. When the form is submitted and handleFormSubmit is called, we call publishStarship with the form data. Now our service object starts to manage the state of the web service changes. If the returned promise is success, we call setStarship with the initialStarshipState to clean the form.

And that's all, we have three custom Hooks to retrieve initial data, retrieve individual items and post data. You can see the full project here: https://github.com/camilosw/react-hooks-services

Final Thoughts

React Hooks are a great addition but don't try to overuse them when there are simpler and well-established solutions, like the Promise instead of useEffect on our sending content example.

There is another benefit when using Hooks. If you look closer, you will see that our components became basically presentational, because we moved the stateful logic to our custom Hooks. There is an established pattern to separate logic from presentation, called container/presentational, where you put the logic in a parent component and presentation in children components. That pattern was initially ideated by Dan Abramov, but now that we have Hooks, Dan Abramov advises to use less that pattern in favor of using Hooks: https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0

Probably you hate using strings to name states and blamed me for doing that, but if you are using Typescript, you are safe, because Typescript will fail if you misspell the state name and you will get autocomplete for free in VS Code (and other editors probably). Anyway, you can use booleans if you prefer.

Top comments (6)

Collapse
 
tksilicon profile image
ThankGod Ukachukwu • Edited

"Inside that function, I'm using the Hook useState to manage the web service states. Note that I need to define the exact type of data that will be managed by the result state passing the generic Starships not Starship. I'm initializing the Hook with the interface ServiceInit of the union type Service, so the only property allowed is status with the string 'loading'."

You initialized with ServiceLoading not ServiceInit and the generic Starships not Starship. I am following your tutorial to solve a challenge so I am following the tutorial closely.

Collapse
 
vmandal profile image
Vikram

Nice work

Collapse
 
amsteffensen profile image
Andreas Steffensen

Thank you for this, this really saved me. Very well written, and link too a GitHub repo also makes this worth vile.

Collapse
 
dance2die profile image
Sung M. Kim

Thank you, Camilo.
It was a fun & educational article as you provided best practices as well.

Collapse
 
josemunoz profile image
José Muñoz

Beware of this pattern, once Suspense for data fetching is out (soon) this pattern will be obsolete.

Collapse
 
camilomejia profile image
Camilo Mejía

You're right, but if you need to start a project just now and can't wait until suspense for data fetching is out, this is still valid.