Flow typed AJAX responses with React Hooks

Published by at 13th June 2020 12:50 pm

I'm a big fan of type systems in general. Using Psalm to find missing type declarations and incorrect calls in PHP has helped me out tremendously. However, I'm not a big fan of Typescript. The idea of creating a whole new language, primarily just to add types to Javascript, strikes me as a fundamentally bad idea given how many languages that compile to Javascript have fallen by the wayside. Flow seems like a much better approach since it adds types to the language rather than creating a new language, and I've been using it on my React components for a good while now. However, there are a few edge cases that can be difficult to figure out, and one of those is any generic AJAX component that may be reused for different requests.

A while back I wrote the following custom hook, loosely inspired by axios-hooks (but using the Fetch API) to make a query to a GraphQL endpoint:

useFetch.js
1import { useCallback, useState, useEffect } from "react";
2
3function useFetch(url, query) {
4 const [data, setData] = useState(null);
5 const [loading, setLoading] = useState(false);
6 const [error, setError] = useState(false)
7
8 const fetchData = useCallback(() => {
9 setLoading(true);
10 fetch(url, {
11 method: 'POST',
12 headers: {
13 'Content-Type': 'application/json',
14 'Accept': 'application/json',
15 },
16 body: JSON.stringify({query: query})
17 }).then(r => r.json())
18 .then((data) => {
19 setData(data.data);
20 setLoading(false);
21 setError(false);
22 });
23 }, [url, query]);
24
25 useEffect(() => {
26 fetchData();
27 }, [url, query, fetchData]);
28
29 return [{
30 data: data,
31 loading: loading,
32 error: error
33 }, fetchData];
34};
35
36export default useFetch;

When called, the hook receives two parameters, the URL to hit, and the query to make, and returns an array that contains a function for reloading, and an object containing the following values:

  • loading - a boolean that specifies if the hook is loading right now
  • error - a boolean that specifies if an error has occurred
  • data - the response data from the endpoint, or null

Using this hook, it was then possible to make an AJAX request when a component was loaded to populate the data, as in this example:

App.js
1import React from 'react';
2import useFetch from './Hooks/useFetch';
3import marked from 'marked';
4import './App.css';
5
6function App() {
7 const url = `/graphql`;
8 const query = `query {
9 posts {
10 title
11 slug
12 content
13 tags {
14 name
15 }
16 }
17 }`;
18
19 const [{data, loading, error}] = useFetch(url, query);
20
21 if (loading) {
22 return (<h1>Loading...</h1>);
23 }
24
25 if (error) {
26 return (<h1>Error!</h1>);
27 }
28
29 const posts = data ? data.posts.map((item) => (
30 <div key={item.slug}>
31 <h2>{item.title}</h2>
32 <div dangerouslySetInnerHTML={{__html: marked(item.content)}} />
33 </div>
34 )) : [];
35 return (
36 <div className="App">
37 {posts}
38 </div>
39 );
40}
41
42export default App;

This hook is simple, and easy to reuse. However, it's difficult to type the value of data correctly, since it will be different for different endpoints, and given that it may be reused for almost any endpoint, you can't cover all the acceptable response types. We need to be able to specify the response that is acceptable in that particular context.

Generics to the rescue

Flow provides a solution for this in the shape of generic types. By passing in a polymorphic type using <T> in the function declaration, we can then refer to that type when specifying what data should look like:

useFetch.js
1//@flow
2import { useCallback, useState, useEffect } from "react";
3
4function useFetch<T>(url: string, query: string): [{
5 data: ?T,
6 loading: boolean,
7 error: boolean
8}, () => void] {
9 const [data, setData]: [?T, ((?T => ?T) | ?T) => void] = useState(null);
10 const [loading, setLoading]: [boolean, ((boolean => boolean) | boolean) => void] = useState(false);
11 const [error, setError]: [boolean, ((boolean => boolean) | boolean) => void] = useState(false)
12
13 const fetchData = useCallback(() => {
14 setLoading(true);
15 fetch(url, {
16 method: 'POST',
17 headers: {
18 'Content-Type': 'application/json',
19 'Accept': 'application/json',
20 },
21 body: JSON.stringify({query: query})
22 }).then(r => r.json())
23 .then((data) => {
24 setData(data.data);
25 setLoading(false);
26 setError(false);
27 });
28 }, [url, query]);
29
30 useEffect(() => {
31 fetchData();
32 }, [url, query, fetchData]);
33
34 return [{
35 data: data,
36 loading: loading,
37 error: error
38 }, fetchData];
39};
40
41export default useFetch;

Then, when calling the hook, we can define a type that represents the expected shape of the data (here called <Data>), and specify that type when calling the hook, as in this example:

App.js
1//@flow
2import React from 'react';
3import useFetch from './Hooks/useFetch';
4import marked from 'marked';
5import './App.css';
6
7type Data = {
8 posts: Array<{
9 title: string,
10 slug: string,
11 content: string,
12 name: Array<string>
13 }>
14};
15
16function App() {
17 const url = `/graphql`;
18 const query = `query {
19 posts {
20 title
21 slug
22 content
23 tags {
24 name
25 }
26 }
27 }`;
28
29 const [{data, loading, error}] = useFetch<Data>(url, query);
30
31 if (loading) {
32 return (<h1>Loading...</h1>);
33 }
34
35 if (error) {
36 return (<h1>Error!</h1>);
37 }
38
39 const posts = data ? data.posts.map((item) => (
40 <div key={item.slug}>
41 <h2>{item.title}</h2>
42 <div dangerouslySetInnerHTML={{__html: marked(item.content)}} />
43 </div>
44 )) : [];
45 return (
46 <div className="App">
47 {posts}
48 </div>
49 );
50}
51
52export default App;

That way, we can specify a completely different shape for our response data every time we call a different endpoint, without creating a different hook for every different endpoint, and still enjoy properly typed responses from our hook.

Generics can be useful for many other purposes, such as specifying the contents of collections. For instance, if you had a Collection object, you could use a generic type to specify that any one instance must consist of instances of a given type. Flow would then flag it as an error if you assigned an item of the wrong type to that collection, thus making some unit tests redundant.