We're live-coding on Twitch! Join us!
Build a Meal Ticketing App with GraphQL and Apollo React Hooks

Build a Meal Ticketing App with GraphQL and Apollo React Hooks

I was at a conference this year and they handed attendees meal tickets for the after-party. It was a super amazing conference and I loved meeting everyone there, but dang... I forgot my meal ticket at the hotel and was starving while everyone else ate at the after-party! They served good looking milkshakes and the best could do was stare helplessly.

Why am I telling you about how I almost missed out on a great after-party dinner? How is this related to React Hooks and Apollo? It’s not. However, I did end up building a meal ticket tracker and I used GraphQL Apollo React Hooks – so you get it.

A meal ticket tracker allows you to issue digital meal tickets to attendees and not rely on paper tickets. You can still give them the ticket, but also have a system that tracks if the ticket has been used or not. When an attendee is served a meal, you can invalidate the ticket and the ticket cannot be reused. This way, attendees that forget their tickets can use an ID like badge to claim their meal.

Don’t tell me this is too serious. People take their food very seriously!

In summary, we need our demo to:

  1. Generate meal tickets from attendees data
  2. Find meal ticket based on attendee name or ID
  3. Invalidate meal ticket in real-time.
  4. Issue ticket in real-time (maybe someone joined from wait list).

At the end of the day, we want to make sure that we write a React app using only React Hooks. The stack we’ll be working with is quite simple. We’ll use React with hooks on the frontend and 8base for the backend.

Requirements

To follow this tutorial, a basic understanding of React and Node.js is required. Please ensure that you have Node and npm/yarn installed before you begin.

We’ll also be making GraphQL queries in the project, so some familiarity with GraphQL is helpful.

Essential Reading: Learn React from Scratch! (2019 Edition)

Setting up 8base

To get started using 8base, follow the steps listed below:

1) Sign Up

If you have an existing account, visit your 8base Dashboard select an existing Workspace. If you don’t have an account, create one on 8base. Their free plan will work for what we need.

2) Building the Data Model

In the workspace, navigate to the Data Builder page and click on “+Add Table” to start building the data model. Were going to create two tables with the following fields.

Attendees | Field | Type | Description | Options | | --- | --- | --- | --- | | name | Text | The name of the attendee | mandatory=True |

MealTickets | Field | Type | Description | Options | | --- | --- | --- | --- | | valid | Switch | Ascertains the ticket’s validity | format=True/False
default=True |

Once the two tables are created, we need to establish a relationship between them. This can be done by dragging one table onto the other. However, let's build the one-to-many relationship manually on the Attendees table.

Attendees | Field | Type | Description | Options | | --- | --- | --- | --- | | name | Text | The name of the attendee | mandatory=True | | MealTickets | Table | An attendees meal ticket's | table=MealTickets
Relation Field Name=Owner
Allow multiple MealTickets per Attendee=True
Allow multiple Attendees per MealTicket=False |

Before movin on, lets add some dummy records to our database. This can be done manually by clicking on a table and navigating to the data tab. However, you can also use the API Explorer. This time around, lets just run the following GraphQL mutation.

mutation {
  steve: attendeeCreate(data: { 
    name: "Steve Jones",
    mealTickets: {
      create: [{ valid: true }]
    }
  }) { ...attendeeResponse }

  bonnie: attendeeCreate(data: { 
    name: "Bonnie Riot",
    mealTickets: {
      create: [{ valid: true }]
    }
  }) { ...attendeeResponse }

  jack: attendeeCreate(data: { 
    name: "Jack Olark",
    mealTickets: {
      create: [{ valid: false }]
    }
  }) { ...attendeeResponse }
}

fragment attendeeResponse on Attendee {
  id 
  name
  mealTickets {
    count
  }
}

GraphQL mutations handle record creates, updates, and deletes. Using aliases (the keys named'steve', 'bonnie', etc.), we're able to run multiple operations in a single request. The data.mealTickets.create value will actually create an associated meal ticket record after the attendee record is created. That value is currently an array since attendees can have many tickets. Lastly, a fragment is simply a templates for our queries. Its contents could be written out in the query response plainly.

3) Roles and Permissions

To allow app users to securely access the API with appropriate permissions, were going to create a custom role. Navigate to Settings > Roles and create new role with the name "Meal Ticketer". Once created, click the role and lets update its permissions.

Here we can update the Meal Ticketer's (a person using the app) permissions. For example, they should be able to do things like create attendees or mealTickets, and update mealTickets but not delete them. Let check the appropriate boxes and select the needed options.

Meal Ticketer | Table | Create | Read | Update | Delete | Fields | | --- | --- | --- | --- | --- | --- | | Attendees | True | All Records | All Records | False | *Defaults | | MealTickets | True | All Records | All Records | False | *Defaults |

Now, all unauthenticated users who call the workspace API endpoint and have the Meal Ticketer role can permform these actions.

4) Authentication Profile

Setting up authentication will allow users to sign-up, log-in, and log-out of the app. Users should be authenticated to view the list of attendees and to perform tasks like allocating and invalidating tickets. We’ll configure 8base to handle authentication.

Navigate to the Authentication page to begin the setup. We’ll need to create an authentication profile that contains roles, allowed urls, etc.

To create a new authentication profile, click the button with a plus-sign button and specify the following values:

Option Value Notes
Name "Default Guest Auth" Choose any descriptive name
Type 8base authentication Find more auth info in the docs
Self Signup Open to all Leave Off if using a free workspace
Roles Meal Ticketer Multiple roles can be assigned to user on sign up

Add the new authentication profile. The information that’s now displayed is useful when connecting the client application to the authentication profile. Note the Authentication Profile Id, the Client ID and the Domain; these values will come in handy later in the article.

Next, we’ll set the custom domains. Scroll down to where you see Custom Domains. This is where you can provide routes that’ll be used during authentication. Update your URLs to be similar to the screenshot below.

Note: make sure the \_localhost:port__ number matches that which your React app will run on_!

5) Getting the Workspace API Endpoint

Lastly, let’s copy our workspace’s API endpoint. This endpoint is unique to our workspace, and is where we will send our GraphQL queries URL.

There are a few ways to obtain the endpoint. However, just navigate to the workspace Home page and you’ll find the endpoint in the bottom left. 

Developing React Hooks

I created a starter project so that setup is easy and to ensure the tutorial focuses on getting started with 8base and GraphQL. The skeleton of the application has already been set up, this includes styling and project structure.

Cloning the App

Run the following command to clone the repository:

git clone https://github.com/christiannwamba/meal-tickets-tutorial-starter

Move into the folder and install the project’s dependencies by running the following command:

cd meal-tickets && yarn install

Now let’s start the React app server by running yarn start in the root folder of the project.

Authentication

Dive into the codebase and open the src/authClient.js file. We’re going to replace the placeholder values with those that were created in the Authentication Profile; the AUTH_CLIENT_ID and AUTH_PROFILE_ID. Also, take a minute to read the in-code documentation.

/_ src/authClient.js _/

/_*
 _ Creating an Authentication Profile in 8base will provide 
 _ you with a AUTH_CLIENT_ID and AUTH_PROFILE_ID.
 _ 
 */

const AUTH0_CLIENT_ID = 'AUTH_CLIENT_ID';
const AUTH_PROFILE_ID = 'AUTH_PROFILE_ID';

In the src/index.js file, we import the authClient and supply it to the 8base App Provider. The App Provider component wraps our application and uses Apollo Client to help manage the authentication flow. In this file, lets update the URI with our workspace API endpoint.

const URI = '<WORKSPACE_API_ENDPOINT>';

Login

We’ll now implement login in the src/pages/Index.js component. The 8base-react-sdk exports an AuthContext function that checks whether the user is authorized before render. Wrapping AuthContext with the useContext hook gives access to some variables, including isAuthorized. When not authorized, the authClient is used to authenticate the user.

Open the src/pages/index.js and add the following imports to the top of the file:

import React, { useContext } from 'react';
import { AuthContext } from '@8base/react-sdk';
import LoginImage from '../login.svg';

Let's read to in-code documentation to get a better grasp of what's happening. In-particular, pay attention to how we are wrapping AuthContext in use context.

/_*
 _ Wrap AuthContext in useContext and unpack
 _ isAuthorized and authClient
 _ 
 */

export default function Index() {
  const date = new Date().toDateString();
  const { isAuthorized, authClient } = useContext(AuthContext);
  return (
    <>
      <div className="page-info">
        <img src="images/meal-ticket.svg" alt="" />
        <h1 className="img-title">meal ticket</h1>
        <p className="date">{date}</p>
      </div>
      {!isAuthorized ? (
        <div className="login-container">
          <img
            src={LoginImage}
            className="login-image"
            alt="Login to use the app"
          />
          <div>
            <button
              className="login-button"
              onClick={() => authClient.authorize()}
            >
              Login
            </button>
          </div>
        </div>
      ) : (
        <ul className="options">
          ...
        </ul>
      )}
    </>
  );
}

Logout

What goes up must come down, and what logs in must logout. Lets head over to the src/App.js file and make some changes to allow this.

We spoke about the AuthContext import in the previous section. Now, we're importing an additional wrapper function called withApollo. It injects the Apollo Client into components passed as an argument. We need access to the Apollo Client to clear the store during logout.

Update the App function to look like the snippet below:

/** 
 **_ Wrap AppRouter with withApollo 
 _**
 **_
 _**/
const RouterApp = withApollo(AppRouter);

// ...
/**
 _ Use RouterApp as application router.
 _ 
 _
 _/
 <RouterApp></RouterApp>

Now, the AppRouter function has access to Apollo Client. So let's look at the AppRouter function that displays a button to trigger the authClient.logout() function, and then clears the store. Update the file to look like the snippet below:

 /_*
  _ On logout, clear the store using 
  _ client and logout via authClient.
  _ 
  */

function AppRouter({ client }) {
  const { isAuthorized, authClient } = useContext(AuthContext);

  const logout = async () => {
    await client.clearStore();
    authClient.logout();
  };

  return (
    <div>
        <>
          {isAuthorized && (
            <div className="logout-container">
              <button className="logout-button" onClick={logout}>
                <p>
                  Logout <span></span>
                </p>
              </button>
            </div>
          )}
          <Route path="/" exact component={Index} />
          <Route path="/auth/callback" component={Auth} />
          ...
        </>
    </div>
  );
}

Now after initiating and completing the login flow, you should see a Logout button at the top of the page.

Nice work! We’ve successfully implemented Authentication in the app. In the next section, we’ll start fetching and displaying records from our GraphQL API. Let’s get to it.

Display Attendees List

We're going to first fetch a list of attendees from 8base. To do that, we need a GraphQL query to fetch the records, as well as drill the returned data down the component tree as a prop.

Open the src/App.js file and add the following imports to the file:

import gql from 'graphql-tag';
import { useQuery } from '@apollo/react-hooks';

The gql function is for writing GraphQL queries and mutations with introspection and useQuery is a hook for fetching data from the 8base endpoint.

Next, we'll look at adding the query that fetches the attendees list, along with their meal tickets. This query gets passed as an argument to the useQuery Apollo-hook. In the same file, update the ATTENDEES_LIST to look like the snippet below:

const ATTENDEES_LIST = gql`{
    attendeesList {
      count
      items {
        name
        id
        mealTickets {
          items {
            id
            valid
          }
        }
      }
    }
  }`;

The useQuery function exposes a loading variable that can be used when awaiting a response; we'll use it to ensure the request is completed before accessing the data variable. This check is in the return statement of the App.js component .

/_*
 _ Unpack loading and data from useQuery hook.
 _  
 _/
const { loading, data } = useQuery(ATTENDEES_LIST);

{/_ Render loading message when running query _/}
return (
    <div>
      {loading ? ( // add check to ensure the data is fully loaded before displaying.
        <p>Loading...</p>
      ) : (
        <>
          {isAuthorized && (
            <div className="logout-container">
              ...
            </div>
          )}
          <Route path="/" exact component={Index} />
          <Route
            path="/generate/"
            component={() => (
              <Generate
                attendees={data.attendeesList.items} // Pass the list of attendees as props to the component
              />
            )}
          />
          <Route
            path="/tickets/"
            component={() => (
              <Tickets
                attendees={data.attendeesList.items} // Pass the list of attendees as props to the component
              />
            )}
          />
        </>
      )}
    </div>
);

At the start of the return statement, there is a check for when the query request is loading and then we display a loading indicator. When loading is done, the application routes are then rendered and the attendees list is passed as props to the Ticket and Generate components. Within those components, we’ll filter the list.

In the Generate component, let's filter out attendees with valid tickets before doing the opposite in the Tickets component.

Open the src/pages/Generate.js file and add the function below to the bottom of the file. The function filters the attendees list of and only returns those without valid tickets.

function hasInvalidTicket(attendees) {
  return attendees.filter(({ mealTickets: { items: mealTickets = [] } }) => {
    const hasInvalidTicket = mealTickets.every((ticket) => !ticket.valid);
    return hasInvalidTicket;
  });
}

The function runs through the list of attendees, filtering out those with valid tickets, leaving attendees that need fresh tickets. Update the rest of the component to look the following way:


export default function Generate({ attendees }) {
  const attendeesWithNoOrInvalidTickets = hasInvalidTicket(attendees);

  return (
    <>
      <BackButton />
      <div className="search-wrapper-wp">
        ...
      </div>
      <div className="main-wrapper">
        <h2 className="page-title">SEARCH RESULT</h2>
        <ul className="search-result">
          {/_ Loop through attendees list and display their name _/ }
          {attendeesWithNoOrInvalidTickets.map((attendee) => (
            <Ticket key={attendee.id} attendee={attendee}>
              <GenerateButton/>
            </Ticket>
          ))}
        </ul>
      </div>
    </>
  );
}

Head over to the src/pages/Tickets.js file and do the opposite for the component. In this component, we’ll filter out users with invalid tickets and leave those with valid tickets. Add the following function at the bottom of the page:

function hasValidTicket(attendees) {
  return attendees.filter((attendee) => {
    const mealTickets = attendee.mealTickets.items;
    const validTickets = mealTickets.filter((ticket) => ticket.valid);
    return validTickets.length > 0;
  });
}

The function checks the list for attendees with active tickets and filters out those without. Update the component to display the attendees returned from the function:

export default function Tickets({ attendees }) {
  const attendeesWithValidTickets = hasValidTicket(attendees);

  return (
    <>
      <BackButton />
      <div className="search-wrapper-wp">
        ...
      </div>
      <div className="main-wrapper">
        <h2 className="page-title">SEARCH RESULT</h2>
        <ul className="search-result">
          {attendeesWithValidTickets.map((attendee) => (
            <Ticket attendee={attendee} key={attendee.id}>
              <InvalidateButton/>
            </Ticket>
          ))}
        </ul>
      </div>
    </>
  );
}

You can find the complete file for the Generate page and the Tickets page on Github.

We're getting there! Next, we’ll work on generating tickets for an attendee.

Generating tickets for attendees

To handle generating tickets, we’ll use the useMutation hook from Apollo. We'll use a mutation to create meal tickets, and add the ticket data using a variable. In the src/pages/Generate.js file, add the following imports and mutation string to the file:

import gql from 'graphql-tag';
import { useMutation } from '@apollo/react-hooks';

const GENERATE_TICKET = gql`mutation GenerateTicket($data: MealTicketCreateInput!) {
    mealTicketCreate(data: $data) {
      id
    }
  }`;

The GENERATE_TICKET value will be passed to the useMutation hook an argument. Calling the hook with the string will return a function for running mutation. On a side note, it's practice for the return function to share a similar name with the mutation operation.

const [mealTicketCreate] = useMutation(GENERATE_TICKET);

Now, lets generate a ticket! Update the src/pages/Generate.js file with the following function:

/_ src/pages/Generate.js _/

const onGenerateClick = (attendeeId, generateTicket) => {
    const data = {
    variables: {
        data: {
        valid: true,
        owner: {
            connect: {
            id: attendeeId,
            },
        },
        },
    },
    };
    generateTicket(data);
};

export default function Generate({ attendees }) {
    ...
}

The onGenerateClick function takes two arguments, the attendeeId and a generateTicket mutation function for running mutations. Within the function, we curate the body of the mutation.

The data object for the intended ticket has two properties:

  • valid: the current state of the ticket. The validity state will be set to false after the ticket is used.
  • owner: the attendee will be connected to the ticket using the attendeeId.

The function is ready, let's put it to use. Pass the function to the onClick prop of the Generate component. Follow the snippet below:

// src/pages/Generate.js
...
export default function Generate({ attendees }) {
  const [mealTicketCreate, { data }] = useMutation(GENERATE_TICKET);
  const attendeesWithNoOrInvalidTickets = hasInvalidTicket(attendees);
  return (
    <>
      <BackButton />
      <div className="search-wrapper-wp">
        ...
      </div>
      <div className="main-wrapper">
        <h2 className="page-title">SEARCH RESULT</h2>
        <ul className="search-result">
          {attendeesWithNoOrInvalidTickets.map((attendee) => (
            <Ticket key={attendee.id} attendee={attendee}>
              <GenerateButton
                onClick={() => onGenerateClick(attendee.id, mealTicketCreate)} // --- call the function with the attendeeId and the mutation function
              ></GenerateButton>
            </Ticket>
          ))}
        </ul>
      </div>
    </>
  );
}
...

Call the onGenerateClick function with the attendee.id value and the mutation function mealTicketCreate. After this update, you should be able to successfully create tickets for an attendee.

After the ticket is generated, the attendee should move from the Generate page to the Tickets page. We'll add the ability to see this in real-time using subscriptions later in the article. We can generate tickets, how do we invalidate them? Let's figure it out together in the next section.

Invalidating attendee tickets

After an attendee fetches a meal, it's only right to invalidate their ticket to prevent them from coming over for a second or even a third round. To achieve this, we'll run on the assumption that a user will only have one valid ticket at a time. So when invalidating, we find the only active ticket and make it invalid. Doing this will move the attendee back to the Generate page.

Enough talk, let's put code on editor; add the following imports to the src/pages/Tickets.js page.

import gql from 'graphql-tag';
import { useMutation } from '@apollo/react-hooks';

Let's add a mutation string for invalidating tickets. Add the following in the file:

const INVALIDATE_TICKET = gql`mutation InvalidateTicket($data: MealTicketUpdateInput!) {
    mealTicketUpdate(data: $data) {
      id
    }
  }`;

Pass the string to the useMutation hook, the return function will be used in the click handler to run mutations:

export default function Tickets({ attendees }) {
  const [mealTicketUpdate] = useMutation(INVALIDATE_TICKET);
  const attendeesWithValidTickets = hasValidTicket(attendees);
  return <>...</>;
}

The return statement has been omitted for brevity. No changes were made to that section of the component

We can make use oof the mutation function in the event handler, add the following function to the src/pages/Tickets.js file:

    import React, { useEffect } from 'react';
    ...
    const INVALIDATE_TICKET = gql`...`;
    const getValidTicket = (tickets) => {
      return tickets.find((ticket) => ticket.valid);
    };
    const onInvalidateClick = (tickets, inValidateTicket) => {
      const validTicket = getValidTicket(tickets);
      const data = {
        variables: {
          data: {
            id: validTicket.id,
            valid: false,
          },
        },
      };
      inValidateTicket(data);
    };
    export default function Tickets({ attendees, search, searchTerm }) {
      ...
    }

The onInvalidateClick function takes two arguments, the tickets array containing all the tickets belonging to an attendee and the mutation function invalidateTicket. Within the function, we call the getValidTicket function to get the valid ticket of the attendee. With that, we curate the body of the mutation using the id of the ticket and setting it the valid state to false thus invalidating it.

Pass the function to the Invalidate component's onClick prop. After the change the function should look like the snippet below:

// src/pages/Tickets.js

import React, { useEffect } from 'react';
...

const INVALIDATE_TICKET = gql`...`;

const getValidTicket = (tickets) => {
  return tickets.find((ticket) => ticket.valid);
};

const onInvalidateClick = (tickets, inValidateTicket) => {
  ...
};

export default function Tickets({ attendees }) {
  const [mealTicketUpdate] = useMutation(INVALIDATE_TICKET);
  const attendeesWithValidTickets = hasValidTicket(attendees);
  return (
    <>
      <BackButton />
      <div className="search-wrapper-wp">...</div>
      <div className="main-wrapper">
        <h2 className="page-title">SEARCH RESULT</h2>
        <ul className="search-result">
          {attendeesWithValidTickets.map((attendee) => (
            <Ticket attendee={attendee} key={attendee.id}>
              <InvalidateButton
                onClick={() =>
                  onInvalidateClick(
                    attendee.mealTickets.items,
                    mealTicketUpdate
                  )
                }
              ></InvalidateButton>
            </Ticket>
          ))}
        </ul>
      </div>
    </>
  );
}

Call the onInvalidateClick function with the list of tickets attendee.mealTickets.items value and the mutation function mealTicketUpdate. After this update, you should be able to invalidate a ticket, moving the user back to the next Generate page.

Let's consider scrolling through a long list of attendees trying to find an attendee to generate a ticket for or invalidate an existing ticket. It would be a lot easier to search through the list to find an attendee. We have the search bar already, so let's add functionality to search as you type.

Searching

To implement the search feature, we'll make use of the graphql filter object. The name field of the attendee will be checked if it contains the string entered in the search field. Doing this will involve updating the query string in the App component for fetching attendees and passing a variables object to the useQuery hook.

Open the src/App.js file and update the React import to include the useState hook like the snippet below:

// src/App.js
import React, { useState } from 'react';

And then update the GET_ATTENDEES string to look like the snippet below:

// src/App.js
const GET_ATTENDEES = gql`query Attendees($searchTerm: String!) {
    attendeesList(filter: { name: { contains: $searchTerm } }) {
      count
      items {
        name
        id
        mealTickets {
          items {
            id
            valid
          }
        }
      }
    }
  }`;

Then, we'll update the line calling the useQuery hook to include the variables object and create a state value using the useState hook.

// src/App.js

import React, { useState } from 'react';
...

const GET_ATTENDEES = gql`...`;

function App() {
 ...
}

function AppRouter() {
  const [searchTerm, setSearchTerm] = useState('');
  const { isAuthorized, authClient } = useContext(AuthContext);
  const { loading, data } = useQuery(GET_ATTENDEES, {
    variables: { searchTerm },
  });

  const logout = async () => {
    ...
  };
  return <div>...</div>;
}

...

The searchTerm and setSearchTerm will be you used to manage the update from the search field. Within the AppRouter component, pass the searchTerm and the setSearchTerm values to the Generate and Tickets components.

// src/App.js

function AppRouter() {
  ...

  return (
    <div>
      {loading ? (
        <p>Loading...</p>
      ) : (
        <>
          {isAuthorized && (
            ...
          )}
          <Route path="/" exact component={Index} />
          <Route
            path="/generate/"
            component={() => (
              <Generate
                attendees={data.attendeesList.items}
                search={setSearchTerm}
                searchTerm={searchTerm}
              />
            )}
          />
          <Route
            path="/tickets/"
            component={() => (
              <Tickets
                attendees={data.attendeesList.items}
                search={setSearchTerm}
                searchTerm={searchTerm}
              />
            )}
          />
        </>
      )}
    </div>
  );
}

Within the src/pages/Generate.js file, pass the searchTerm and search props to the search input.

// src/pages/Generate.js

export default function Generate({ attendees, search, searchTerm }) {
  const [mealTicketCreate, { data }] = useMutation(GENERATE_TICKET);
  const attendeesWithNoOrInvalidTickets = hasInvalidTicket(attendees);
  return (
    <>
      <div className="search-wrapper-wp">
        <div className="search-wrapper">
          <a href="#search">
            <svg className="search">
              <use href="images/icons.svg#search"></use>
            </svg>
          </a>
          <input
            id="search"
            type="search"
            value={searchTerm}
            onChange={(e) => search(e.target.value)}
          />
        </div>
      </div>
      <div className="main-wrapper">
        ...
      </div>
    </>
  );
}

And within the src/pages/Tickets.js do same, similar to the screenshot shown below:

// src/pages/Tickets.js

export default function Tickets({ attendees, search, searchTerm }) {
  const [mealTicketUpdate, { data }] = useMutation(INVALIDATE_TICKET);
  const attendeesWithValidTickets = hasValidTicket(attendees);
  return (
    <>
      <div className="search-wrapper-wp">
        <div className="search-wrapper">
          <a href="#search">
            <svg className="search">
              <use href="images/icons.svg#search"></use>
            </svg>
          </a>
          <input
            id="search"
            type="search"
            value={searchTerm}
            onChange={(e) => search(e.target.value)}
          />
        </div>
      </div>
      <div className="main-wrapper">
        ...
      </div>
    </>
  );
}

Now you can easily search for an attendee

Searching should function properly now if you try. Visit http://localhost:3000/tickets or http://localhost:3000/generate to test it out. In the next section, we’ll set up the app to receive real-time updates using GraphQL subscriptions.

Real-time updates with subscriptions

To get started with subscriptions, we’ll have to create a GraphQL subscription string to manage fetching of updates. We’ll write a string to subscribe to the record of the ticket, open the src/App.js file and make the following changes.

Include the useSubscription hook as one of the named imports from apollo-hooks and the useEffect hook from React:

import React, { useState, useContext, useEffect } from 'react';
import { useQuery, useSubscription } from '@apollo/react-hooks';

Add the following string next to get real-time updates from the Tickets record:

const TICKETS_SUB = gql`subscription AttendeeSub {
    MealTickets {
      node {
        owner {
          id
        }
        id
        valid
      }
      mutation
    }
  }`;

This should go below the GET_ATTENDEES variable. Next, we'll pass the string to the useSubscription hook:

// src/App.js

...

function AppRouter({ client }) {
  const [searchTerm, setSearchTerm] = useState('');
  ...
  const [attendees, setAttendees] = useState([]); // Add a state variable for storing the attendees array.
  const subscription = useSubscription(ATTENDEES_SUB);

  const logout = async () => {
    ...
  };
  useEffect(() => {
    if (!loading) {
      setAttendees(data.attendeesList.items);
      updateAttendeeRecord(subscription, attendees, setAttendees);
    }
  }, [data, subscription.data]);
  return (
    <div>
      {loading ? (
        <p>Loading...</p>
      ) : (
        <>
          ...
          <Route
            path="/generate/"
            component={() => (
              <Generate
                attendees={attendees} //update prop to use the state variable
                search={setSearchTerm}
                searchTerm={searchTerm}
              />
            )}
          />
          <Route
            path="/tickets/"
            component={() => (
              <Tickets
                attendees={attendees} //update prop to use the state variable
                search={setSearchTerm}
                searchTerm={searchTerm}
              />
            )}
          />
        </>
      )}
    </div>
  );
}
...

In the snippet above, we created a state variable for storing the list the of attendees returned from the useQuery hook. Below that, we call the useSubscription passing TICKETS_SUB as an argument. When there are updates to the Tickets collection, the data will be available in the subscription variable.

Within the useEffect hook, we check if request to fetch the attendees list is still processing using the loading variable. If loading is false, we’ll set the data returned to the state as attendees. Below that, we have a function we haven’t yet defined, the function will use the updates from the subscription to update the list of attendees. We also updated the attendees prop passed to the Tickets and Generate components, from data.attendeeList.items to attendees.

Add the following function to the bottom of the file:

// src/App.js

const updateAttendeeRecord = (subRes, attendees, setAttendees) => {
  if (subRes.data) {
    const { node, mutation } = subRes.data.MealTickets;
    const updatedAttendees = attendees.map((attendee) => {
      if (node.owner.id === attendee.id) {
        const tickets = attendee.mealTickets.items;
        if (mutation === 'create') {
          attendee.mealTickets = {
            items: attendee.mealTickets.items.concat(node),
          };
          return attendee;
        } else if (mutation === 'update') {
          const updatedTickets = attendee.mealTickets.items.map((item) => {
            if (item.id === node.id) {
              return {
                ...item,
                ...node,
              };
            }
            return item;
          });
          attendee.mealTickets = {
            items: updatedTickets,
          };
          return attendee;
        }
      } else {
        return attendee;
      }
    });
    setAttendees(updatedAttendees);
  }
};

In the function, we check if there’s data returned from the subscription, then we loop through the attendees list and check for an attendee with an id matching the ticket owner id.

In the next execution, we get the mutation type and add the new ticket to the ticket list of the attendee if the mutation type is create and if the type is update we find the ticket and replace the ticket fields with that returned from the subscription update.

In the end, we set the updatedAttendees to state. Now, you can open two browser tabs and try generating/invalidating tickets to see attendees move between the lists in real-time.

You can find the code for this article on GitHub. Happy coding

Like this article? Follow @codebeast on Twitter