Flow typed AJAX responses with React Hooks
Published by Matthew Daly 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.js1import { useCallback, useState, useEffect } from "react";23function useFetch(url, query) {4 const [data, setData] = useState(null);5 const [loading, setLoading] = useState(false);6 const [error, setError] = useState(false)78 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]);2425 useEffect(() => {26 fetchData();27 }, [url, query, fetchData]);2829 return [{30 data: data,31 loading: loading,32 error: error33 }, fetchData];34};3536export 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 nowerror
- a boolean that specifies if an error has occurreddata
- 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.js1import React from 'react';2import useFetch from './Hooks/useFetch';3import marked from 'marked';4import './App.css';56function App() {7 const url = `/graphql`;8 const query = `query {9 posts {10 title11 slug12 content13 tags {14 name15 }16 }17 }`;1819 const [{data, loading, error}] = useFetch(url, query);2021 if (loading) {22 return (<h1>Loading...</h1>);23 }2425 if (error) {26 return (<h1>Error!</h1>);27 }2829 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}4142export 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.js1//@flow2import { useCallback, useState, useEffect } from "react";34function useFetch<T>(url: string, query: string): [{5 data: ?T,6 loading: boolean,7 error: boolean8}, () => 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)1213 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]);2930 useEffect(() => {31 fetchData();32 }, [url, query, fetchData]);3334 return [{35 data: data,36 loading: loading,37 error: error38 }, fetchData];39};4041export 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.js1//@flow2import React from 'react';3import useFetch from './Hooks/useFetch';4import marked from 'marked';5import './App.css';67type Data = {8 posts: Array<{9 title: string,10 slug: string,11 content: string,12 name: Array<string>13 }>14};1516function App() {17 const url = `/graphql`;18 const query = `query {19 posts {20 title21 slug22 content23 tags {24 name25 }26 }27 }`;2829 const [{data, loading, error}] = useFetch<Data>(url, query);3031 if (loading) {32 return (<h1>Loading...</h1>);33 }3435 if (error) {36 return (<h1>Error!</h1>);37 }3839 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}5152export 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.