TinyHouse: A Fullstack React Masterclass with TypeScript and GraphQL - part 6

CUSTOM USEQUERY AND LOADING/ERROR STATES

Though our useQuery Hook works as intended, we haven’t taken into account the tracking of the loading and error states of our queries.

LOADING

We’ll first address the loading state of our request. When we say loading, we’re essentially referring to being able to track the status of our asynchronous request. If the request is in flight, the UI should reflect this with a loading indicator of sorts. And when complete, we should be presented with the expected data.

To keep track of loading, we’ll introduce a new loading field into the State interface of the state object tracked in our custom useQuery Hook. We’ll declare the type of the loading field as boolean .

interface State<TData> {
  data: TData | null;
  loading: boolean;
}

We’ll initialize the loading value as false in our state initialization.

export const useQuery = <TData = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false
  });

  // ...
};

At the beginning of the fetchApi() method within the memoized fetch callback, we’ll set the loading state to true while also specifying that our state data is still null . When a request is complete we’ll set loading back to false .

export const useQuery = <TData = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false
  });

  const fetch = useCallback(() => {
    const fetchApi = async () => {
      setState({ data: null, loading: true });

      const { data } = await server.fetch<TData>({
        query
      });

      setState({ data, loading: false });
    };

    fetchApi();
  }, [query]);

  // ...
};

We’ve already used the spread syntax to return everything within state at the end of useQuery . We can now destruct the loading property from the useQuery Hook used in the <Listings> component.

If loading is ever true in our <Listings> component, we’ll render a simple header tag that says 'Loading...' . When loading is set back to false, the title and the listings list is to be shown.

export const Listings = ({ title }: Props) => {
  const { data, loading, refetch } = useQuery<ListingsData>(LISTINGS);

  // ...

  if (loading) {
    return <h2>Loading...</h2>;
  }

  return (
    <div>
      <h2>{title}</h2>
      {listingsList}
    </div>
  );
};

We’ll ensure both the Node server and React client apps are running.

server $: npm run start
client $: npm run start

And in the browser, we’ll now notice a brief 'Loading...' message when the query request is in flight.

ERRORS

We’ll now address what would happen if our server.fetch() function was to error out since our <Listings> component isn’t currently prepared to handle this.

With Apollo Server and GraphQL, errors can be a little unique. Oftentimes when a query has failed and returns an error - our server may treat that query as successful since the query request was made successfully.

Let’s see an example of this. We’ll briefly dive back into out Node server application and take a look at the listings resolver function within the server/src/graphql/resolvers/Listing/index.ts file.

export const listingResolvers: IResolvers = {
  Query: {
    listings: async (
      _root: undefined,
      _args: {},
      { db }: { db: Database }
    ): Promise<Listing[]> => {
      return await db.listings.find({}).toArray();
    }
  }
  // ...
};

The listings resolver simply returns all the listing documents from the database collection we’ve set up in MongoDB Atlas. We’ll temporarily throw an error before the return statement of the resolver function to mimic if an error was to occur.

export const listingResolvers: IResolvers = {
  Query: {
    listings: async (
      _root: undefined,
      _args: {},
      { db }: { db: Database }
    ): Promise<Listing[]> => {
      throw new Error("Error!");
      return await db.listings.find({}).toArray();
    }
  }
  // ...
};

We can refresh our browser to attempt to query the listings field again. We’re not going to get the information we’re expected from the API but if we take a look at our browser’s Network tab and find the post API request made, we can see that the request made to /api was successful with status code 200!

If we take a look at the response from the API request, whether through the browser Network tab or GraphQL Playground, we can see the server returns data as null and an errors array is populated.

When an error is thrown in Apollo Server, the error gets populated inside the errors array that contains information about the errors added by Apollo Server. This complies with the GraphQL specification - if an error is thrown, the field should return data of null while the error is to be added to the errors field of the response.

On this note, we should specify that the response from the server can return an errors field along with a data field.

The errors field from our server is an array where each item has a few different properties to display different information ( message , extensions , locations , etc). In the src/lib/api/server.ts file, we’ll create an interface for what an error would look like when returned from the server. We’ll create an Error interface and keep things simple by stating message as the only property we intend to access from an error.

client/src/lib/api/server.ts

interface Error {
  message: string;
}

In our return statement from the server.fetch() function, we’ll state in our type assertion that the returned object will contain an errors field of type Error[] .

export const server = {
  fetch: async <TData = any, TVariables = any>(body: Body<TVariables>) => {
    const res = await fetch("/api", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify(body)
    });

    return res.json() as Promise<{
      data: TData;
      errors: Error[];
    }>;
  }
};

To be on the safe side, we should also guard for when the server.fetch() function fails with an error status code. In this case, the GraphQL request won’t even be made since the actual fetch request will fail.

The window fetch() function provides an ok property within the response which can be used to determine if the response was successful (i.e. the response status is in the range 200 - 299 ).

We’ll use the res.ok field to check if the response status code is ever not 200 . If the response code is an error code, we’ll throw an error stating 'failed to fetch from server' .

The server.ts file will now look like the following:

client/src/lib/api/server.ts

interface Body<TVariables> {
  query: string;
  variables?: TVariables;
}

interface Error {
  message: string;
}

export const server = {
  fetch: async <TData = any, TVariables = any>(body: Body<TVariables>) => {
    const res = await fetch("/api", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify(body)
    });

    if (!res.ok) {
      throw new Error("failed to fetch from server");
    }

    return res.json() as Promise<{ data: TData; errors: Error[] }>;
  }
};

The res.ok check is used to check for if the server response ever returns a status that is not successful. The returned errors field from our response is when a request is successfully made but our GraphQL API returns an error within the errors field.

We’ll now head back to our useQuery Hook and handle if the server.fetch() function is to ever throw or return an error. We won’t look to pass error messages or information from our server.fetch() function to our React components. The useQuery Hook will simply return an error boolean with which our components will retrieve and handle.

First, we’ll introduce a new field to our State interface with the label of error and a type value of boolean .

client/src/lib/api/useQuery.ts

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

We’ll initialize our error state property with a value of false .

export const useQuery = <TData = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false,
    error: false
  });

  // ...
};

To help catch errors thrown from our server.fetch() function, we’ll wrap the content of our fetchApi() function in the fetch callback within a try / catch statement. If a request error is thrown within our server.fetch() function, the catch block of our fetchApi() method will run and we’ll simply set our state by having data be set to null , loading to false , and the error field to true .

We’ll also ensure the error properties are set to false in the setState() functions within our try block.

export const useQuery = <TData = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false
  });

  const fetch = useCallback(() => {
    const fetchApi = async () => {
      try {
        setState({
          data: null,
          loading: true,
          error: false
        });

        const { data } = await server.fetch<TData>({
          query
        });

        setState({ data, loading: false, error: false });
      } catch {
        setState({
          data: null,
          loading: false,
          error: true
        });
      }
    };

    fetchApi();
  }, [query]);

  // ...
};

If an error is thrown from our server.fetch() function and caught in our fetchApi() function, we’ll like to surface this error in our browser console. We’ll use the console.error() function to do so and place the console.error() in a throw statement to prevent any further execution of our code.

export const useQuery = <TData = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false
  });

  const fetch = useCallback(() => {
    const fetchApi = async () => {
      try {
        setState({
          data: null,
          loading: true,
          error: false
        });

        const { data } = await server.fetch<TData>({
          query
        });

        setState({ data, loading: false, error: false });
      } catch (err) {
        setState({
          data: null,
          loading: false,
          error: true
        });
        throw console.error(err);
      }
    };

    fetchApi();
  }, [query]);

  // ...
};

Now if our server.fetch() completely errors out and returns an error status code, the error will be surfaced in our browser console when we attempt to query listings !

The remaining thing we need to consider is if the server.fetch() function is successful (i.e. status code 200 ) but the resolver in our GraphQL API returns an error in the errors field of the response. This is where we’ll destruct the errors field from the response of the server.fetch() function in our useQuery Hook. We’ll check to see if the errors array is populated, and if it is, we’ll throw an error so the catch statement can run and set the error message to the browser console.

Our useQuery Hook in the complete state will appear as follows:

client/src/lib/api/useQuery.ts

export const useQuery = <TData = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false,
    error: false
  });

  const fetch = useCallback(() => {
    const fetchApi = async () => {
      try {
        setState({ data: null, loading: true, error: false });

        const { data, errors } = await server.fetch<TData>({
          query
        });

        if (errors && errors.length) {
          throw new Error(errors[0].message);
        }

        setState({ data, loading: false, error: false });
      } catch (err) {
        setState({ data: null, loading: false, error: true });
        throw console.error(err);
      }
    };

    fetchApi();
  }, [query]);

  useEffect(() => {
    fetch();
  }, [fetch]);

  return { ...state, refetch: fetch };
};

We’re only capturing and logging the first error message we can find in the errors array from our response. For a more robust solution, one can look to capture and log all the errors that different field resolvers may throw within the errors array.

We’ll now appropriately log a GraphQL error that exists in the errors array of our server request. By still having the thrown error in the listings resolver, we’ll be able to see this error when we try to query listings in our browser.

Though errors are being appropriately captured now, we should surface and display something to the UI when this happens. In our <Listings> component, we’ll destruct the error field from the returned statement of the useQuery Hook.

If the error property from the useQuery Hook is ever true , we’ll have our <Listings> component simply render a header message that says something went wrong.

export const Listings = ({ title }: Props) => {
  const { data, loading, error, refetch } = useQuery<ListingsData>(LISTINGS);

  // ...

  if (error) {
    return <h2>Uh oh! Something went wrong - please try again later :(</h2>;
  }

  return (
    <div>
      <h2>{title}</h2>
      {listingsList}
    </div>
  );
};

The entire <Listings> component will look as follows:

client/src/sections/Listings/Listings.tsx

export const Listings = ({ title }: Props) => {
  const { data, loading, error, refetch } = useQuery<ListingsData>(LISTINGS);

  const deleteListing = async (id: string) => {
    await server.fetch<DeleteListingData, DeleteListingVariables>({
      query: DELETE_LISTING,
      variables: {
        id
      }
    });

    refetch();
  };

  const listings = data ? data.listings : null;

  const listingsList = listings ? (
    <ul>
      {listings.map(listing => {
        return (
          <li key={listing.id}>
            {listing.title}{" "}
            <button onClick={() => deleteListing(listing.id)}>Delete</button>
          </li>
        );
      })}

Now, if our server.fetch() function ever errors out with an error status code or returns errors within an errors field from the request, our useQuery Hook will return a truthy error value.

At this moment, our listings resolver in the backend code throws an error. When we attempt to make our query in the React application, we’ll be displayed with the error message in our UI! If our server was unavailable, we’ll also surface the same error message in the UI as well.

We’ll remove the temporary thrown error message in our listings resolver function in the server code.

And that’s it! Albeit being pretty simple, our useQuery Hook does everything it’s intended to do for now. In our component, we can notice we’re still making a direct server.fetch() function when we intend to run the deleteListing mutation. In the next lesson, we’ll try and see how to have the execution of mutations grouped within a custom useMutation Hook.

There are situations where a partial response and error can be returned from a GraphQL request, where one field from the request is successful, and another field in the request errors. (e.g. the id field in the Listing object type resolves successfully, but the title field in Listing object type errors).

In this situation, our GraphQL response may contain both data within the data field and errors in the errors field. In our application, we’ve resorted to simply displaying an error message in the UI as long as the errors array is populated.

CUSTOM USEMUTATION HOOK

We’ve created a useQuery Hook to help make a GraphQL query request when a component first mounts. In this lesson, we’ll look to create a useMutation Hook that helps prepare a function to make an API mutation request.

GAMEPLAN

Our useMutation Hook will behave differently since we won’t want a mutation to run the moment a component mounts by default. Our useMutation Hook will simply receive the mutation query document to be made.

export const Listings = ({ title }: Props) => {
  useMutation<DeleteListingData, DeleteListingVariables>(DELETE_LISTING);

  // ...
};

And return an array of two values - the first being the request function and the second being an object that contains detail of the request.

export const Listings = ({ title }: Props) => {
  const [deleteListing, { loading, error }] = useMutation<
    DeleteListingData,
    DeleteListingVariables
  >(DELETE_LISTING);

  // ...
};

We’ll get a better understanding of how our useMutation Hook is to behave once we start to create it.

USEMUTATION

We’ll create our useMutation Hook in a file of its own in the src/lib/api folder. We’ll label this file useMutation.ts .

src/
  lib/
    api/
      index.ts
      server.ts
      useMutation.ts
      useQuery.ts
  // ...

Similar to the useQuery Hook, we’re going to need to keep track of some state within our useMutation Hook so we’ll import the useState Hook. Since we’re going to be interacting with the server as well, we’ll import the server object which will help us make the server.fetch() request.

client/src/lib/api/useMutation.ts

import { useState } from "react";
import { server } from "./server";

We’ll create a State interface that describes the shape of the state object we’ll want to maintain. The State interface will have the data , loading , and error fields. The type of data will either be equal to a type variable passed in the interface ( TData ) or null . The loading and error fields will be of type boolean .

client/src/lib/api/useMutation.ts

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

We’ll export and create a const function called useMutation . The useMutation function will accept two type variables - TData and TVariables . TData is to represent the shape of data that can be returned from the mutation while TVariables is to represent the shape of variables the mutation is to accept. Both of the TData and TVariables type variables will have a default type value of any .

Our mutation function, however, will only accept a single required document query parameter.

import { useState } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

export const useMutation = <TData = any, TVariables = any>(query: string) => {};

The term query here is used to reference the GraphQL request that is to be made. One can rename the query parameter to mutation to be more specific.

VARIABLES

We expect variables necessary for our request to be used in our useMutation Hook but we haven’t specified variables as a potential argument in our function. The reason being is how we want our Hook to work. In our use case, we won’t want to pass in the variables when we use the useMutation Hook but instead pass it in the request function the mutation is expected to return.

Let’s go through an example of what we intend to do. Assume the useMutation Hook when used in a component is to return a fetch function that we’ll label as request .

export const Listings = ({ title }: Props) => {
  const [request] = useMutation<DeleteListingData, DeleteListingVariables>(
    DELETE_LISTING
  );
};

Only when the request function is called, will we pass in the variables necessary for the mutation.

export const Listings = ({ title }: Props) => {
  const [request] = useMutation<DeleteListingData, DeleteListingVariables>(
    DELETE_LISTING
  );

  const deleteListing = (id: string) => {
    await request({ id }); // variables is passed in
  };
};

This is simply how we want to set up our useMutation Hook. It’s possible to also pass in variables when we run the Hook function as well.

USEMUTATION

At the beginning of the useMutation Hook, we’ll initialize the state object we’ll want our Hook to maintain. We’ll initialize our state similar to how we’ve done in the useQuery Hook by setting data to null and the loading and error fields to false .

import { useState } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

export const useMutation = <TData = any, TVariables = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false,
    error: false
  });
};

We’ll now create a fetch() function in our useMutation Hook that will be responsible for making our request. fetch() will be an asynchronous function that accepts a variable object that will have a type equal to the TVariables type variable. We’ll also state the variables parameter is an optional parameter since there may be mutations we can create that don’t require any variables.

We’ll introduce a try/catch statement within the fetch() function.

import { useState } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

export const useMutation = <TData = any, TVariables = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false,
    error: false
  });

  const fetch = async (variables?: TVariables) => {
    try {
      // try statement
    } catch {
      // catch statement
    }
  };
};

At the beginning of the try block, we’ll set the state loading property to true since at this point the request will be in-flight. We’ll keep data and error as the original values of null and false respectively.

We’ll then conduct our server.fetch() function, and pass in the query and variables values the server.fetch() function can accept. The server.fetch() function will return an object of data and errors so we’ll destruct those values as well. We’ll also pass along the type variables of data and variables to ensure the information returned from the server.fetch() function is appropriately typed.

import { useState } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

export const useMutation = <TData = any, TVariables = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false,
    error: false
  });

  const fetch = async (variables?: TVariables) => {
    try {
      setState({ data: null, loading: true, error: false });

      const { data, errors } = await server.fetch<TData, TVariables>({
        query,
        variables
      });
    } catch {
      // catch statement
    }
  };
};

In the last lesson, we observed how GraphQL requests could resolve but have errors be returned from our resolver. These errors will be captured in the errors array we’retrieving from the server.fetch() function. We’ll check to see if these errors exist and if so - throw an Error and pass in the error message from the first error object in the errors array.

import { useState } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

export const useMutation = <TData = any, TVariables = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false,
    error: false
  });

  const fetch = async (variables?: TVariables) => {
    try {
      setState({ data: null, loading: true, error: false });

      const { data, errors } = await server.fetch<TData, TVariables>({
        query,
        variables
      });

      if (errors && errors.length) {
        throw new Error(errors[0].message);
      }
    } catch {
      // catch statement
    }
  };
};

If the request is successful and no errors exist, we’ll set the returned data into our state. We’ll also set the loading and error properties to false.

If an error arises for either the request failing or errors being returned from the API, we’ll set the error value in our state to true . We’ll also specify data should be null and loading is false. We’ll capture the error message and look to log the error message in the browser console.

import { useState } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

export const useMutation = <TData = any, TVariables = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false,
    error: false
  });

  const fetch = async (variables?: TVariables) => {
    try {
      setState({ data: null, loading: true, error: false });

      const { data, errors } = await server.fetch<TData, TVariables>({
        query,
        variables
      });

      if (errors && errors.length) {
        throw new Error(errors[0].message);
      }

      setState({ data, loading: false, error: false });
    } catch (err) {
      setState({ data: null, loading: false, error: true });
      throw console.error(err);
    }
  };
};

This will be the scope of our fetch() function. The only thing remaining from our custom useMutation Hook is to return the values we’d want our component to use. For this Hook, we’ll return an array of two values. The first value will be the fetch() function itself and the second value will be the state object.

import { useState } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

export const useMutation = <TData = any, TVariables = any>(query: string) => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false,
    error: false
  });

  const fetch = async (variables?: TVariables) => {
    try {
      setState({ data: null, loading: true, error: false });

      const { data, errors } = await server.fetch<TData, TVariables>({
        query,
        variables
      });

      if (errors && errors.length) {
        throw new Error(errors[0].message);
      }

      setState({ data, loading: false, error: false });
    } catch (err) {
      setState({ data: null, loading: false, error: true });
      throw console.error(err);
    }
  };

  return [fetch, state];
};

We could have very well returned an object of key-value pairs and had the fetch() function as a value in the object. Since we’re returning an array, when we destruct these values in our component, we can simply name the request function as we’d like. This is because arrays aren’t mapped based on a key-value pair but instead on indices.

Our useMutation Hook is now complete! Notice how our useMutation Hook differs from useQuery ? They’re similar in how we’ve constructed a fetch() function and the state object we’re using to keep track of our request. In useMutation however, we’re not using a useEffect Hook since we want the component to determine when a request should be made. We also destruct the values as an array instead of an object.

Let’s have our useMutation Hook exported from our /api folder before we use it in our component.

client/src/lib/api/index.ts

export * from "./server";
export * from "./useMutation";
export * from "./useQuery";

<LISTINGS>

In the <Listings> component, we’ll import the useMutation Hook.

client/src/sections/Listings/Listings.tsx

import { useMutation, useQuery } from "../../lib/api";

We’ll declare the useMutation Hook at the top of the <Listings> component, right after the use of the useQuery Hook. In the useMutation Hook declaration, we’ll pass in the mutation constant object we expect ( DELETE_LISTING ) and the type variables referencing the data and variables of that mutation.

We’ll destruct the values we want from useMutation . We’ll name the fetch() function being destructured deleteListing() , and we’ll destructure the loading and error state from the state object. We won’t have the need for data since we don’t plan on presenting that information in our UI.

export const Listings = ({ title }: Props) => {
  // ...
  const [deleteListing, { loading, error }] = useMutation<
    DeleteListingData,
    DeleteListingVariables
  >(DELETE_LISTING);
};

Since we’re naming the destructured fetch() function as deleteListing() , we’ll rename the component deleteListing() function to handleDeleteListing() . To differentiate the loading and error properties between the useQuery and useMutation Hooks, we’ll rename the loading and error properties from useMutation to deleteListingLoading and deleteListingError respectively.

export const Listings = ({ title }: Props) => {
  // ...
  const [
    deleteListing,
    { loading: deleteListingLoading, error: deleteListingError }
  ] = useMutation<DeleteListingData, DeleteListingVariables>(DELETE_LISTING);
};

TUPLES AND MUTATIONTUPLE

We’ll notice our editor warn us of a TypeScript error when we inspect the loading or error properties from useMutation .

We notice a TypeScript error along the lines of the loading or error property doesn’t exist on type ((variables?: DeleteListingVariables | undefined) => Promise<void>) or State<DeleteListingData> .

The ((variables?: DeleteListingVariables | undefined) => Promise<void>) type refers to the fetch() function in our useMutation Hook that accepts variables and returns Promise<void> . State<DeleteListingData> refers to the state object used in the useMutation Hook.

Why is TypeScript unsure about the type of values destructured from the array? The reason is that when we define arrays, we usually define arrays with a single type . In this case, we’re returning an array with multiple types . TypeScript, therefore, states that the values of this array are either of the fetch() function type or the state object type.

This is where we have to tell the TypeScript compiler the individual type of each element in the array. In other words, there is where we have to define a Tuple type.

From the TypeScript documentation, Tuple types allow us to express an array with a fixed number of elements whose types are known.

In the useMutation.ts file, we’ll look to explicitly define the return type of our useMutation Hook. We’ll define this type as the MutationTuple type alias.

import { useState } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

type MutationTuple = [];

export const useMutation = <TData = any, TVariables = any>(query: string) => {
  // ...
};

The MutationTuple type will receive type variables, TData and TVariables , to help shape the variables argument in the function type and the data property in the state object type.

import { useState } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

type MutationTuple<TData, TVariables> = [];

export const useMutation = <TData = any, TVariables = any>(query: string) => {
  // ...
};

The MutationTuple type alias will be an array of two values.

  • The first value will have the type of a function that accepts an optional variables argument and returns a promise that when resolved is void .
  • The second value will have the type of the State interface.

With the MutationTuple type alias defined, we’ll explicitly state the return type of the useMutation Hook as MutationTuple while passing along the appropriate type variables.

import { useState } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

type MutationTuple<TData, TVariables> = [
  (variables?: TVariables | undefined) => Promise<void>,
  State<TData>
];

export const useMutation = <TData = any, TVariables = any>(
  query: string
): MutationTuple<TData, TVariables> => {
  // ...
};

When we go back to our <Listings> component, we’ll see that the TypeScript error is now gone! This is because each item in the returned array of useMutation has its own unique type.

QUERYRESULT

Though we don’t need to define an explicit type for our useQuery Hook, we’ll annotate a type to keep things consistent between our useQuery and useMutation Hooks. In the useQuery.ts file, we’ll create a QueryResult interface type which is to be the return type of the useQuery Hook.

import { useState, useEffect, useCallback } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

interface QueryResult {}

export const useQuery = <TData = any>(query: string) => {
  // ...
};

The QueryResult interface will essentially contain all the fields in the State interface while introducing a refetch function field. This is where we can take advantage of the extend keyword to extend the State interface. In TypeScript, an interface can extend another interface to copy its own members.

We’ll state the QueryResult interface is to extend the State interface and introduce a refetch property that is to have a function type that returns void . The QueryResult interface is to accept a TData type variable with which it’ll pass down to the State interface declaration it extends.

We’ll assign the return type of the useQuery Hook to the QueryResult interface we set up.

import { useState, useEffect, useCallback } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

interface QueryResult<TData> extends State<TData> {
  refetch: () => void;
}

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  // ...
};

DELETE LISTING

With our useMutation Hook now returning the appropriate types for each returned value, we can look to use the useMutation Hook to help delete a listing.

In the handleDeleteListing() function in our <Listings> component, we’ll remove the use of server.fetch() and simply call the deleteListing() function that’s been destructured from useMutation and pass the id variable needed in our deleteListing mutation.

Since the deleteListing() function from useMutation is asynchronous, we’ll await till it finishes before we make our query refetch.

export const Listings = ({ title }: Props) => {
  // ...
  const [
    deleteListing,
    { loading: deleteListingLoading, error: deleteListingError }
  ] = useMutation<DeleteListingData, DeleteListingVariables>(DELETE_LISTING);

  const handleDeleteListing = async (id: string) => {
    await deleteListing({ id });
    refetch();
  };
};

We’ll want to notify the user when a deletion is in progress and we’ll use the loading property destructured from our useMutation Hook to help us. We’ll create a simple header tag that will say 'Deletion in progress...' when a deletion is in flight. We’ll use a ternary statement to have this header tag kept in a constant variable only when the deleteListingLoading property is true, otherwise, we’ll set the value of the const variable to null . We’ll render this element constant at the bottom of our component return statement.

export const Listings = ({ title }: Props) => {
  // ...
  const [
    deleteListing,
    { loading: deleteListingLoading, error: deleteListingError }
  ] = useMutation<DeleteListingData, DeleteListingVariables>(DELETE_LISTING);

  const handleDeleteListing = async (id: string) => {
    await deleteListing({ id });
    refetch();
  };

  // ...

  const deleteListingLoadingMessage = deleteListingLoading ? (
    <h4>Deletion in progress...</h4>
  ) : null;

  return (
    <div>
      <h2>{title}</h2>
      {listingsList}
      {deleteListingLoadingMessage}
    </div>
  );
};

The user will now be briefly notified when a delete request is in process.

If an error in our mutation ever occurs, we’d want to notify the user as well. Similar to how we’ve created a deleteListingLoadingMessage constant variable, we’ll create a deleteListingErrorMessage constant variable to display an error message when deleteListingError is true . We’ll have deleteListingErrorMessage rendered at the bottom of our component’s return statement as well.

Our <Listings> component in its entirety will look like the following:

client/src/sections/Listings/Listings.tsx

export const Listings = ({ title }: Props) => {
  const { data, loading, error, refetch } = useQuery<ListingsData>(LISTINGS);

  const [
    deleteListing,
    { loading: deleteListingLoading, error: deleteListingError }
  ] = useMutation<DeleteListingData, DeleteListingVariables>(DELETE_LISTING);

  const handleDeleteListing = async (id: string) => {
    await deleteListing({ id });
    refetch();
  };

  const listings = data ? data.listings : null;

  const listingsList = listings ? (
    <ul>
      {listings.map(listing => {
        return (
          <li key={listing.id}>
            {listing.title}{" "}
            <button onClick={() => handleDeleteListing(listing.id)}>
              Delete
            </button>
          </li>
        );
      })}
    </ul>
  ) : null;

  if (loading) {
    return <h2>Loading...</h2>;
  }

  if (error) {
    return <h2>Uh oh! Something went wrong - please try again later :(</h2>;
  }

  const deleteListingLoadingMessage = deleteListingLoading ? (
    <h4>Deletion in progress...</h4>
  ) : null;

  const deleteListingErrorMessage = deleteListingError ? (
    <h4>
      Uh oh! Something went wrong with deleting :(. Please try again soon.
    </h4>
  ) : null;

  return (
    <div>
      <h2>{title}</h2>
      {listingsList}
      {deleteListingLoadingMessage}
      {deleteListingErrorMessage}
    </div>
  );
};

If an error now arises from our deleteListing mutation call, the user will be notified of this in the UI.

Notice how we handle the loading and error states between our query and mutation?

When a query is made, we have no data to show the user. As a result, we’ve resorted to showing a full-page loading or error message when the query is in flight or has errored out respectively. When a mutation fails, however, we’ve opted to not remove the existing UI from the application and instead display the loading and error message at the bottom of the list.

How the loading and error states of an application are handled is due to the design and goals of how an application is to be built. We’ll spend more time discussing this topic in the second part of this course.

Amazing! We’ve successfully created a useMutation Hook that helps abstract the server.fetch() functionality away from components. For any component that may need to trigger a mutation, they can follow the pattern performed here in the <Listings> component.

THE USEREDUCER HOOK

Our custom useQuery and useMutation Hooks work the way we want them to. They return the data we expect from our GraphQL requests and return some status information such as the loading and error states of our requests.

If we take a look at how we’re manipulating the state objects our Hooks are returning, we can see that we’re using the useState Hook to achieve this. This is because useState is one of the primary Hooks given to us by React to manage the state of functional components.

// useQuery
export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  const [state, setState] = useState<State<TData>>({
    data: null,
    loading: false,
    error: false
  });

  // ...
};

For both the useQuery and useMutation Hooks, the state we’re trying to manipulate and track is an object where each field of the object dictates something about the request. Every function in our fetch that sets the state can be seen to be an action of sorts.

  • The first action sets the loading status to true .
  • The second action sets the data in state to the data received.
  • The last action if ever to occur is to set the error status to true .

Since we have a clear pattern of actions that interact with the same object, we can instead use another state management Hook that React provides called useReducer .

USEREDUCER

We’ll look to first use the useReducer Hook in our custom useQuery Hook, so we’ll import the useReducer Hook from the react library in the useQuery.ts file.

import { useState, useReducer, useEffect, useCallback } from "react";

useReducer behaves very similar to how Redux works.

Redux is a library that adapts the flux pattern to managing state in a client-side application.

useReducer takes the concepts of Redux and allows us to manage data with a similar pattern!

The useReducer Hook takes a reducer() function that receives the current state and an action , and returns the new state. useReducer returns an array of two values and can take three arguments:

  • The first argument is the reducer() function.
  • The second argument is the initial state.
  • The third (optional) argument is an initialization function responsible for initializing the state.
const [state, dispatch] = useReducer(reducer, initialArg, init);

The useReducer Hook will appear more understandable when we start to implement it.

REDUCER

In the useQuery.ts file, we’ll define a simple reducer() function outside of our Hook. A reducer() function is a function that receives the current state and an action that would return the new state.

import { useState, useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

// ...

const reducer = (state, action) => {};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  // ...
};

A switch statement is often used to determine the return value of state based on the action received.

import { useState, useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

// ...

const reducer = (state, action) => {
  switch () {}
}

export const useQuery = <TData = any>(
  query: string
): QueryResult<TData> => {
  // ...
};

The action parameter of the reducer function is to be an object that might contain a payload value we can use to update the state with. action is to usually contain a type property describing what kind of action is being made. action.type will be the expression used in the switch statement to evaluate the returned new state object in the reducer function.

import { useState, useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

// ...

const reducer = (state, action) => {
  switch (action.type) {
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  // ...
};

By convention, action types are often denoted with capital letters. Let’s specify the cases and the returns we expect our reducer to take for each action type. We’ll specify three cases - FETCH , FETCH_SUCCESS , and FETCH_ERROR .

Though this should never happen, we’ll also specify a default case in our switch statement that will throw an error if the action.type does not exist or match either the FETCH , FETCH_SUCCESS , or FETCH_ERROR types.

import { useState, useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

// ...

const reducer = (state, action) => {
  switch (action.type) {
    case "FETCH":
      return; // ...
    case "FETCH_SUCCESS":
      return; // ...
    case "FETCH_ERROR":
      return; // ...
    default:
      throw new Error();
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  // ...
};

For each of the cases in our switch statement, we’d want the reducer to return a new updated state object. We have access to the initial state as the first argument of the reducer() function and to conform to the Redux/Flux pattern of how state should be treated immutable (i.e. can’t be changed), we’ll always return new state objects for each case.

Our useReducer Hook is to interact with a state object similar to what we had before. The state object contains the data , loading , and error fields.

For the first FETCH action that is to be fired, we simply want to make the loading field of state to true . We’ll use the spread syntax to place the values of state in our new object and update the loading property to true .

import { useState, useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

// ...

const reducer = (state, action) => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return; // ...
    case "FETCH_ERROR":
      return; // ...
    default:
      throw new Error();
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  // ...
};

In the FETCH_SUCCESS scenario, we’d want to update the data in our state as well as ensure loading and error are false. This new payload that we’d want to apply will arrive from the action itself.

import { useState, useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

// ...

const reducer = (state, action) => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return {
        ...state,
        data: action.payload,
        loading: false,
        error: false
      };
    case "FETCH_ERROR":
      return; // ...
    default:
      throw new Error();
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  // ...
};

In the FETCH_ERROR case, we’ll want to ensure loading is false while error is set to true.

import { useState, useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

// ...

const reducer = (state, action) => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return {
        ...state,
        data: action.payload,
        loading: false,
        error: false
      };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: true };
    default:
      throw new Error();
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  // ...
};

We’ve yet to specify the type values of the state and action parameters in our reducer() function. We’ve already created the State interface that describes the shape of the state object we want to interact with so we’ll annotate the assign the state parameter in our reducer() function with the State interface type. We need access to the TData type variable that the State interface accepts so we’ll say our reducer() function will receive a TData type variable as well.

import { useState, useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

// ...

const reducer = <TData>(state: State<TData>, action) => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return {
        ...state,
        data: action.payload,
        loading: false,
        error: false
      };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: true };
    default:
      throw new Error();
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  // ...
};

We can create a new type alias labeled Action that represents the shapes of the different action objects that can pass through our reducer. Each action is an object that contains a type field where the type fields match that of the case labels we’ve created in our reducer() function - FETCH , FETCH_SUCCESS , and FETCH_ERROR .

import { useState, useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

// ...

type Action = { type: "FETCH" } | { type: "FETCH_SUCCESS" } | { type: "FETCH_ERROR" };

const reducer = <TData>(state: State<TData>, action) => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return {
        ...state,
        data: action.payload,
        loading: false,
        error: false
      };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: true };
    default:
      throw new Error();
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  // ...
};

In our FETCH_SUCCESS action type, we expect a payload that has the shape of our data from the server. Similar to our State interface, we’ll state that the Action type alias is to accept a TData type variable which will be used to describe the shape of the action payload for the FETCH_SUCCESS case. We can then assign the Action type to the action argument in our reducer() function.

import { useState, useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

// ...

type Action<TData> =
  | { type: "FETCH" }
  | { type: "FETCH_SUCCESS"; payload: TData }
  | { type: "FETCH_ERROR" };

const reducer = <TData>(state: State<TData>, action: Action<TData>) => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return {
        ...state,
        data: action.payload,
        loading: false,
        error: false
      };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: true };
    default:
      throw new Error();
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  // ...
};

Finally, we can explicitly define the return type of the reducer() function to ensure we’re always returning the same object in each switch case. Since the reducer() function is to return an updated state object, we’ll say its return type is State<TData> .

import { useState, useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

// ...

type Action<TData> =
  | { type: "FETCH" }
  | { type: "FETCH_SUCCESS"; payload: TData }
  | { type: "FETCH_ERROR" };

const reducer = <TData>(state: State<TData>, action: Action<TData>): State<TData> => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return {
        ...state,
        data: action.payload,
        loading: false,
        error: false
      };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: true };
    default:
      throw new Error();
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  // ...
};

USEREDUCER

With our reducer() function established, we can now look to use it in a useReducer Hook declared at the top of the useQuery Hook function.

The useReducer Hook returns two values in a tuple - the state object itself and a dispatch function used to trigger an action .

// ...

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  const [state, dispatch] = useReducer();

  // ...
};

The useReducer Hook takes a minimum of two arguments - the first being the reducer() function itself and the second being the initial state. We’ll pass in the reducer() function we’ve created and declare the initial state object like we’ve done with the useState Hook ( data is null , loading is false , and error is false ).

// ...

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  const [state, dispatch] = useReducer(reducer, {
    data: null,
    loading: false,
    error: false
  });

  // ...
};

We could look to try and introduce type variables to the useReducer() function itself. However, the useReducer Hook does a good job in inferring the type of state and dispatch based on the type of the reducer() function defined. If we take a look at the initial state object being passed in to our useReducer statement - we can see that loading and error are recognized to have the boolean type while data is recognized as unknown .

The unknown type was introduced in TypeScript v3 and behaves similar to the any type with some minor differences. You can read more about the unknown type in the TypeScript documentation.

The data property within state is currently unknown because we haven’t passed a value for the TData type variable the reducer() function expects. Here’s where we can do something interesting that can help us achieve this. Instead of passing the reducer() function directly to the useReducer Hook, we can pass in a function that returns the expected reducer() function . This will help us pass along the TData type variable from the useQuery Hook to the reducer() function.

Right above the use of the useReducer Hook, we’ll create a fetchReducer constant that’s simply equal to the result of the reducer() function while passing in the appropriate type variable.

We’ll pass the fetchReducer property as the first argument of the useReducer() function.

// ...

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  const fetchReducer = reducer<TData>();
  const [state, dispatch] = useReducer(fetchReducer, {
    data: null,
    loading: false,
    error: false
  });

  // ...
};

Now, we’ll change the reducer() function to be a function that returns another function with the reducer capabilities.

// ...

const reducer = <TData>() => (state: State<TData>, action: Action<TData>) => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return {
        ...state,
        data: action.payload,
        loading: false,
        error: false
      };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: true };
    default:
      throw new Error();
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  const fetchReducer = reducer<TData>();
  const [state, dispatch] = useReducer(fetchReducer, {
    data: null,
    loading: false,
    error: false
  });

  // ...
};

Our useReducer Hook is now appropriately set up! We can remove the use of the useState Hook and where we’ve used the setState() function in our fetch functionality. In the fetchApi() function within the fetch callback, we’ll now use the dispatch property given to us from useReducer to dispatch actions at every stage of the request.

At the beginning of the fetchApi() method, we’ll dispatch the FETCH action. Upon success, we’ll dispatch the FETCH_SUCCESS action with the appropriate payload. On error, we’ll dispatch the FETCH_ERROR action.

With all the changes made, our useQuery.ts file will now look like the following:

client/src/lib/api/useQuery.ts

import { useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

interface QueryResult<TData> extends State<TData> {
  refetch: () => void;
}

type Action<TData> =
  | { type: "FETCH" }
  | { type: "FETCH_SUCCESS"; payload: TData }
  | { type: "FETCH_ERROR" };

const reducer = <TData>() => (
  state: State<TData>,
  action: Action<TData>
): State<TData> => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return { ...state, data: action.payload, loading: false, error: false };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: true };
    default:
      throw new Error();
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  const fetchReducer = reducer<TData>();
  const [state, dispatch] = useReducer(fetchReducer, {
    data: null,
    loading: false,
    error: false
  });

  const fetch = useCallback(() => {
    const fetchApi = async () => {
      try {
        dispatch({ type: "FETCH" });

        const { data, errors } = await server.fetch<TData>({
          query
        });

        if (errors && errors.length) {
          throw new Error();
        }

        dispatch({ type: "FETCH_SUCCESS", payload: data });
      } catch {
        dispatch({ type: "FETCH_ERROR" });
      }
    };

    fetchApi();
  }, [query]);

  useEffect(() => {
    fetch();
  }, [fetch]);

  return { ...state, refetch: fetch };
};

And that’s it! There’s nothing else we’d need to change. If we have our Node and React servers running, we’ll see that our useQuery Hook works as intended and our app loads the listings information as expected.

If we take a look of what we’ve done with the useReducer Hook, we can see what we’ve achieved is similar to what we had before by changing the values of the different fields in our state at different points of our request. The disadvantage with useReducer is it requires a little more boilerplate and understanding than using the useState Hook. The advantage of using the useReducer Hook is that it’s more preferable to use for complex state objects with multiple sub-values due to the decoupling of the updates that happen to our state from the actions themselves.

USEMUTATION

We’ll now update our useMutation Hook in the useMutation.ts file to also use the useReducer Hook. Since our state and actions mimic one another between the useQuery and useMutation Hooks, we’ll follow a similar pattern to migrate our useMutation Hook from using useState to useReducer .

  • We’ll import the useReducer Hook
  • We’ll define an Action type alias that’s the same as the one in our useQuery Hook.
  • We’ll create a reducer() function that is the same as the one our useQuery Hook.
  • We’ll implement the fetchReducer() function and the use of the useReducer Hook at the top of the useMutation Hook function.
  • Finally, we’ll specify the different dispatches at different points of the mutation request.

With the above changes made, our useMutation.ts file will look like the following:

client/src/lib/api/useMutation.ts

import { useReducer } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

type MutationTuple<TData, TVariables> = [
  (variables?: TVariables | undefined) => Promise<void>,
  State<TData>
];

type Action<TData> =
  | { type: "FETCH" }
  | { type: "FETCH_SUCCESS"; payload: TData }
  | { type: "FETCH_ERROR" };

const reducer = <TData>() => (state: State<TData>, action: Action<TData>) => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return { ...state, data: action.payload, loading: false, error: false };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: true };
    default:
      throw new Error();
  }
};

export const useMutation = <TData, TVariables = {}>(
  query: string
): MutationTuple<TData, TVariables> => {
  const fetchReducer = reducer<TData>();
  const [state, dispatch] = useReducer(fetchReducer, {
    data: null,
    loading: false,
    error: false
  });

  const fetch = async (variables?: TVariables) => {
    try {
      dispatch({ type: "FETCH" });

      const { data, errors } = await server.fetch<TData, TVariables>({
        query,
        variables
      });

      if (errors && errors.length) {
        throw new Error(errors[0].message);
      }

      dispatch({ type: "FETCH_SUCCESS", payload: data });
    } catch (err) {
      dispatch({ type: "FETCH_ERROR" });
      throw console.error(err);
    }
  };

  return [fetch, state];
};

Our useMutation Hook will continue to work as intended by allowing us to delete a listing from the list of listings presented in our UI.

Since our useQuery and useMutation Hooks behave very similar to one another, we can extrapolate a lot of the shared content between the two files to a shared utility file or helper. We’ll keep the similar information between the two Hooks separate but you’re more than welcome to extrapolate some of this content to a shared location.

This brings us to the end of this lesson as well as the end of our custom Hooks implementation. In the next module, we’ll discuss some of the potential shortcomings with our custom implementation and what we’ll be using instead moving forward!

MODULE 7 SUMMARY

In this module, we’ve introduced a few different Hooks to allow us to manipulate and handle information in our <Listings> component. By the end of the module, we created and used the custom Hooks, useQuery and useMutation .

USEQUERY

The useQuery Hook is responsible for consolidating the information in having a GraphQL query be run the moment the component mounts. It returns a series of information such as the data that is to be returned from a query as well as the loading and error status of the query request. It also returns a refetch property which allows components to refetch a query whenever the component may want to.

To keep track of the state of information within our query, we used React’s useReducer Hook. Initially, we used the useState Hook which allows us to add React state to functional components. We moved to use the useReducer Hook to get a clearer separation between updates to the state object and actions needed to update the state object.

The useEffect Hook is used to invoke the query request the moment the component mounts. To be able to extract the function needed to make the request outside of the useEffect Hook, we used the useCallback Hook to memoize the fetch() function to never run until the query parameter value is changed. query is unlikely to ever change since it’s to be a constant value declared outside of components.

client/src/lib/api/useQuery.ts

import { useReducer, useEffect, useCallback } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

interface QueryResult<TData> extends State<TData> {
  refetch: () => void;
}

type Action<TData> =
  | { type: "FETCH" }
  | { type: "FETCH_SUCCESS"; payload: TData }
  | { type: "FETCH_ERROR" };

const reducer = <TData>() => (
  state: State<TData>,
  action: Action<TData>
): State<TData> => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return { ...state, data: action.payload, loading: false, error: false };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: true };
    default:
      throw new Error();
  }
};

export const useQuery = <TData = any>(query: string): QueryResult<TData> => {
  const fetchReducer = reducer<TData>();
  const [state, dispatch] = useReducer(fetchReducer, {
    data: null,
    loading: false,
    error: false
  });

  const fetch = useCallback(() => {
    const fetchApi = async () => {
      try {
        dispatch({ type: "FETCH" });

        const { data, errors } = await server.fetch<TData>({
          query
        });

        if (errors && errors.length) {
          throw new Error();
        }

        dispatch({ type: "FETCH_SUCCESS", payload: data });
      } catch {
        dispatch({ type: "FETCH_ERROR" });
      }
    };

    fetchApi();
  }, [query]);

  useEffect(() => {
    fetch();
  }, [fetch]);

  return { ...state, refetch: fetch };
};

USEMUTATION

The useMutation Hook is similar to the useQuery Hook except for where a useEffect Hook isn’t used to make the GraphQL request when the component first mounts.

client/src/lib/api/useMutation.ts

import { useReducer } from "react";
import { server } from "./server";

interface State<TData> {
  data: TData | null;
  loading: boolean;
  error: boolean;
}

type MutationTuple<TData, TVariables> = [
  (variables?: TVariables | undefined) => Promise<void>,
  State<TData>
];

type Action<TData> =
  | { type: "FETCH" }
  | { type: "FETCH_SUCCESS"; payload: TData }
  | { type: "FETCH_ERROR" };

const reducer = <TData>() => (
  state: State<TData>,
  action: Action<TData>
) => {
  switch (action.type) {
    case "FETCH":
      return { ...state, loading: true };
    case "FETCH_SUCCESS":
      return {
        ...state,
        data: action.payload,
        loading: false,
        error: false
      };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: true };
    default:
      throw new Error();
  }
};

export const useMutation = <TData, TVariables = {}>(
  query: string
): MutationTuple<TData, TVariables> => {
  const fetchReducer = reducer<TData>();
  const [state, dispatch] = useReducer(fetchReducer, {
    data: null,
    loading: false,
    error: false
  });

  const fetch = async (variables?: TVariables) => {
    try {
      dispatch({ type: "FETCH" });

      const { data, errors } = await server.fetch<
        TData,
        TVariables
      >({
        query,
        variables
      });

      if (errors && errors.length) {
        throw new Error(errors[0].message);
      }

      dispatch({ type: "FETCH_SUCCESS", payload: data });
    } catch (err) {
      dispatch({ type: "FETCH_ERROR" });
      throw console.error(err);
    }
  };

  return [fetch, state];
};

<LISTINGS>

The <Listings> component uses the useQuery and useMutation Hooks to make the GraphQL requests and destruct the values that are needed. The query and mutation result values are used to determine what should be displayed in the UI of the <Listings> component.

client/src/sections/Listings/Listings.tsx

import React from "react";
import { useQuery, useMutation } from "../../lib/api";
import {
  DeleteListingData,
  DeleteListingVariables,
  ListingsData
} from "./types";

const LISTINGS = `
  query Listings {
    listings {
      id
      title
      image
      address
      price
      numOfGuests
      numOfBeds
      numOfBaths
      rating
    }
  }
`;

const DELETE_LISTING = `
  mutation DeleteListing($id: ID!) {
    deleteListing(id: $id) {
      id
    }
  }
`;

interface Props {
  title: string;
}

export const Listings = ({ title }: Props) => {
  const { data, loading, error, refetch } = useQuery<
    ListingsData
  >(LISTINGS);

  const [
    deleteListing,
    {
      loading: deleteListingLoading,
      error: deleteListingError
    }
  ] = useMutation<
    DeleteListingData,
    DeleteListingVariables
  >(DELETE_LISTING);

  const handleDeleteListing = async (id: string) => {
    await deleteListing({ id });
    refetch();
  };

  const listings = data ? data.listings : null;

  const listingsList = listings ? (
    <ul>
      {listings.map(listing => {
        return (
          <li key={listing.id}>
            {listing.title}{" "}
            <button
              onClick={() =>
                handleDeleteListing(listing.id)
              }
            >
              Delete
            </button>
          </li>
        );
      })}
    </ul>
  ) : null;

  if (loading) {
    return <h2>Loading...</h2>;
  }

  if (error) {
    return (
      <h2>
        Uh oh! Something went wrong - please try again later
        :(
      </h2>
    );
  }

  const deleteListingLoadingMessage = deleteListingLoading ? (
    <h4>Deletion in progress...</h4>
  ) : null;

  const deleteListingErrorMessage = deleteListingError ? (
    <h4>
      Uh oh! Something went wrong with deleting :(. Please
      try again soon.
    </h4>
  ) : null;

  return (
    <div>
      <h2>{title}</h2>
      {listingsList}
      {deleteListingLoadingMessage}
      {deleteListingErrorMessage}
    </div>
  );
};

MOVING FORWARD

Though we’re able to proceed with what we’ve done to build a larger application, there are limitations to what we’ve done.

For example, assume we had numerous components and when a component (like <Listings> ) is first rendered, we make a GraphQL query. When we navigate elsewhere and remount the same component again, it’ll be nice to have the data we’ve already queried cached in our UI instead of having to make a repeat query to get the same data we already have.

Our useQuery Hook aims to make a query the moment the component mounts. But what if we wanted to make a query by only invoking an action (e.g. clicking a button, entering a form, etc.). Should we use the useMutation Hook for this?

What if we wanted more complicated functionality with how we handle our GraphQL requests. For example, what if we were able to refetch another query based on the fact that a certain query or mutation has been made. As an example, when we delete a listing, assume we want to refetch a User query that provides information needed on the same page. We could try and tailor what we have to make this happen but this is where things start to get a lot more complicated .

This is where the Apollo Client and the React Apollo library fits in.

The Apollo GraphQL documentation tells us that Apollo Client is a complete state management library. It allows us to fetch data and structure our code in a predictable and declarative format that’s consistent with modern React practices. Some of the features it provides are declarative data fetching, an excellent developer experience and is designed for Modern React.

In the next module, we’re going to install the React Apollo library of Apollo Client and we’re going to use the Hooks given to us from React Apollo in our <Listings> component.