DEV Community

Cover image for React Form State Persistency : useFormikContext + Apollo Client + GraphQL Code Generator + Typescript
murat
murat

Posted on • Updated on

React Form State Persistency : useFormikContext + Apollo Client + GraphQL Code Generator + Typescript

Introduction

In this article, we will create a sample Reactjs application to demonstrate form data persistency between routes making use of apollo cache. Since our main goal is to demonstrate the use of apollo cache as an application state container, we won't be dealing with fancy form design. Yet, a bare minimum UI design will be applied using Material-UI.
In this sample app, we're using Formik with its 2.0.3 version that allows us create a form context with useFormikContex hook. Since this is not an article specifically for Formik. We're just using its basic form functionalities.

You can test the working demo before we begin.
Github project is here as well.

Lets start...

Construct the project

We're using Create React App like the vast majority of the react applications as a starter template ;

npx create-react-app apollo-forms --typescript

And need to install initial dependencies. First material-ui

yarn add @material-ui/core clsx

React router;

yarn add react-router-dom history @types/react-router-dom -D @types/history -D

Formik;

yarn add formik

Now let's add our form pages and routes;

src/pages/Cars.tsx

import * as React from "react";
import { Formik, useFormikContext } from "formik";
import TextField from "@material-ui/core/TextField";
import {
  Grid,
  Button,
  makeStyles,
  Theme,
  createStyles,
  Checkbox,
  FormControlLabel,
  Select,
  MenuItem,
  InputLabel,
  FormControl
} from "@material-ui/core";
import DisplayFormikState from "./DisplayFormikState";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    button: {
      margin: theme.spacing(1),
      width: 250
    },
    input: {
      width: 250
    },
    formControl: {
      width: 250
    }
  })
);

type Car = {
  brand: string;
  model: string;
  year: number;
  fastEnough: boolean;
};

const CarForm = () => {
  const classes = useStyles();
  const formik = useFormikContext<Car>();
  return (
    <form>
      <Grid container direction="column" justify="center" alignItems="center">
        <TextField
          className={classes.input}
          name="brand"
          label="Brand"
          value={formik.values.brand}
          onChange={formik.handleChange}
          variant="outlined"
          margin="normal"
        />

        <TextField
          className={classes.input}
          name="model"
          label="Model"
          value={formik.values.model}
          onChange={formik.handleChange}
          variant="outlined"
          margin="normal"
        />

        <FormControl
          margin="normal"
          variant="outlined"
          className={classes.formControl}
        >
          <InputLabel id="demo-simple-select-outlined-label">Year</InputLabel>
          <Select
            labelId="demo-simple-select-outlined-label"
            id="demo-simple-select-outlined"
            value={formik.values.year}
            onChange={e => {
              formik.setFieldValue("year", e.target.value);
            }}
            labelWidth={30}
          >
            <MenuItem value="">
              <em>None</em>
            </MenuItem>
            <MenuItem value={2017}>2017</MenuItem>
            <MenuItem value={2018}>2018</MenuItem>
            <MenuItem value={2019}>2019</MenuItem>
          </Select>
        </FormControl>
        <FormControlLabel
          control={
            <Checkbox
              name="fastEnough"
              checked={formik.values.fastEnough}
              value="fastEnough"
              onChange={e => {
                formik.setFieldValue("fastEnough", e.target.checked);
              }}
            />
          }
          label="Fast Enough"
        ></FormControlLabel>

        <Button
          variant="contained"
          color="primary"
          className={classes.button}
          onClick={() => formik.submitForm()}
        >
          Persist Cars
        </Button>
      </Grid>
      <DisplayFormikState {...formik.values} />
    </form>
  );
};

interface ICars {}

const Cars: React.FunctionComponent<ICars> = (props: ICars) => {
  return (
    <Formik
      initialValues={{
        brand: "",
        model: "",
        year: "",
        fastEnough: false
      }}
      onSubmit={() => alert("Nowhere to persist :-(")}
    >
      <CarForm />
    </Formik>
  );
};

export default Cars;

src/pages/Cities.tsx

import * as React from "react";
import { Formik, useFormikContext } from "formik";
import TextField from "@material-ui/core/TextField";
import {
  Grid,
  Button,
  makeStyles,
  Theme,
  createStyles
} from "@material-ui/core";
import DisplayFormikState from "./DisplayFormikState";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    button: {
      margin: theme.spacing(1),
      width: 250
    },
    input: {
      width: 250
    }
  })
);

type City = {
  name: "";
  country: "";
  population: "";
};

const CityForm = () => {
  const classes = useStyles();
  const formik = useFormikContext<City>();

  return (
    <form>
      <Grid container direction="column" justify="center" alignItems="center">
        <TextField
          className={classes.input}
          name="name"
          label="Name"
          value={formik.values.name}
          onChange={formik.handleChange}
          variant="outlined"
          margin="normal"
        />

        <TextField
          className={classes.input}
          name="country"
          label="Country"
          value={formik.values.country}
          onChange={formik.handleChange}
          variant="outlined"
          margin="normal"
        />

        <TextField
          className={classes.input}
          name="population"
          label="Population"
          value={formik.values.population}
          onChange={formik.handleChange}
          variant="outlined"
          margin="normal"
        />
        <Button
          variant="contained"
          color="primary"
          className={classes.button}
          onClick={() => formik.submitForm()}
        >
          Persist Cities
        </Button>
      </Grid>
      <DisplayFormikState {...formik.values} />
    </form>
  );
};

interface ICities {}

const Cities: React.FunctionComponent<ICities> = (props: ICities) => {
  return (
    <Formik
      initialValues={{
        brand: "",
        model: "",
        year: ""
      }}
      onSubmit={() => alert("Nowhere to persist :-(")}
    >
      <CityForm />
    </Formik>
  );
};

export default Cities;

src/pages/Home.tsx

import * as React from "react";

const Home = () => {
  return <h1>welcome to apollo forms!</h1>;
};

export default Home;

src/pages/Routes.tsx

import * as React from "react";
import { Router, Switch, Route } from "react-router";
import { Link } from "react-router-dom";
import { createBrowserHistory } from "history";
import Cars from "./Cars";
import Cities from "./Cities";
import Home from "./Home";
import {
  AppBar,
  Toolbar,
  makeStyles,
  createStyles,
  Theme
} from "@material-ui/core";

const history = createBrowserHistory();

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    href: {
      margin: 20,
      color: "white"
    }
  })
);

const Routes = () => {
  const classes = useStyles();
  return (
    <Router history={history}>
      <div>
        <AppBar position="static">
          <Toolbar>
            <Link className={classes.href} to="/">
              Home
            </Link>
            <Link className={classes.href} to="/cars">
              Cars
            </Link>
            <Link className={classes.href} to="/cities">
              Cities
            </Link>
          </Toolbar>
        </AppBar>

        {/* A <Switch> looks through its children <Route>s and
        renders the first one that matches the current URL. */}
        <Switch>
          <Route path="/cars">
            <Cars />
          </Route>
          <Route path="/cities">
            <Cities />
          </Route>
          <Route exact path="/">
            <Home />
          </Route>
        </Switch>
      </div>
    </Router>
  );
};

export default Routes;

src/pages/App.tsx

import React from "react";
import "./App.css";
import Routes from "./pages/Routes";

const App = () => {
  return (
    <div className="App">
      <Routes />
    </div>
  );
};

export default App;

src/pages/DisplayFormikState.tsx

import * as React from "react";

const DisplayFormikState = (formikProps: any) => (
  <div style={{ margin: "1rem 0" }}>
    <h3 style={{ fontFamily: "monospace" }} />
    <pre
      style={{
        background: "#f6f8fa",
        fontSize: ".9rem",
        padding: ".5rem"
      }}
    >
      <strong>props</strong> = {JSON.stringify(formikProps, null, 2)}
    </pre>
  </div>
);

export default DisplayFormikState;

At this point, we have a basic app with Cars and Cities pages and we can navigate between. Nothing spacial so far. Form data wich we entered can not be persisted yet.
Now we can ask the eternal question; how to persist the form states? So that when we route back to our page we can find our form filled with previous data.

Keeping the form state

State management is one of the most important topics in React. A few years ago Redux and Mobx were the way to go. Today we have React Context as of React 16.3. When dealing with form states, we may attempt to keep our form in sync with our React context. This seems very logical and easy; just bind the form controls value properties to our context entity's relevant properties in the context and be happy! Very soon we'd discover that this causes undesired re-renders and results in terrible performance...

Formik documentation indicates that the form state is ephemeral. And it should stay so. We may think 'Ok we can update the React Context upon form submission, that's fine enough'. This is quite logical indeed. There are tons of documents on the web about using React Context. However, if we are using GraphQL we have another option; using Apollo Cache to keep the form state between routes...

GraphQL & Apollo Client & graphql-code-generator

GraphQL is an awesome technology that lets us write our backend in very neat and imperative way independent of the language. There are wonderful resources on the web to go into details of GraphQL.

Of course it's not only for backend. We develop our frontend applications making use of GraphQL query & mutation paradigm. Oftenly, frontend teams drive the transition toward GraphQL. PayPal success story is a must read.

I believe, two things are indispensible especially for large development teams; typescript & code generators. As the complexity of your app increases, developing with confidence and ease is crucial. Let's add Apollo & GraphQL to our sample app;

yarn add @apollo/react-hooks apollo-cache-inmemory apollo-client graphql graphql-tag react-apollo

And graphql-code-generator

@graphql-codegen/add @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo @graphql-codegen/typescript-resolvers

Note: tsconfig.json file must have "strictNullChecks": false. Otherwise you'd receive compile time errors.

Now GraphQL queries and mutations. graphql-code-generator will go through this file to generate types;

src/queries.ts

import gql from "graphql-tag";

const QUERY_CAR = gql`
  query carForm {
    carForm @client {
      brand
      model
      year
      fastEnough
    }
  }
`;

const QUERY_CITY = gql`
  query cityForm {
    cityForm @client {
      name
      country
      population
    }
  }
`;

const PERSIST_CAR_FORM = gql`
  mutation persistCarForm($args: CarFormInput!) {
    persistCarForm(carFormInput: $args) @client
  }
`;

const PERSIST_CITY_FORM = gql`
  mutation persistCityForm($args: CityFormInput!) {
    persistCityForm(cityFormInput: $args) @client
  }
`;

export const Queries = {
  QUERY_CAR,
  QUERY_CITY
};

export const Mutations = {
  PERSIST_CAR_FORM,
  PERSIST_CITY_FORM
};

Now we're creating our client side schema definition file that graphql-code-generator will use to generate the GraphQL types and schema definitions;

client-schema.graphql

type Car {
  brand: String
  model: String
  year: String
  fastEnough: Boolean!
}

type City {
  name: String
  country: String
  population: Int
}

input CarFormInput {
  brand: String
  model: String
  year: String
  fastEnough: Boolean!
}

input CityFormInput {
  name: String
  country: String
  population: Int
}

type Query {
  carForm: Car
  cityForm: City
}

type Mutation {
  persistCarForm(carFormInput: CarFormInput!): String
  persistCityForm(cityFormInput: CityFormInput!): String
}

We need to add the configuration file for graphql-code-generator;

codegen.yml

documents:
  - ./src/queries.ts
overwrite: true
generates:
  ./src/graphql/types.tsx:
    schema: client-schema.graphql
    plugins:
      - add: "/* eslint-disable */"
      - typescript
      - typescript-operations
      - typescript-react-apollo
      - typescript-resolvers
    # The combined options of all provided plug-ins
    # More information about the options below:
    # graphql-code-generator.com/docs/plugins/typescript-react-apollo#configuration
    config:
      withHOC: false
      withHooks: true
      withComponent: false
      useIndexSignature: true

Please refer to graphql-code-generator web site for all config details.

Finally, we need to add codegen script to package.json;

  "scripts": {
    "codegen": "gql-gen",
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },

At this point we can run codegen to create src/graphql/types.tsx;

yarn run codegen

If you followed along so far, you're supposed to have src/graphql/types.tsx. You can check the file and its generated types.

Apollo Client & resolvers

Now we need to create Apollo Client and initialize Apollo Cache using src/ApolloProxy.ts;

import { InMemoryCache } from "apollo-cache-inmemory";
import { ApolloClient } from "apollo-client";
import { CarFormQuery, CityFormQuery } from "./graphql/types";
import { resolvers } from "./resolvers";

export const getClient = () => {
  const cache = new InMemoryCache();

  const client = new ApolloClient({
    cache,
    resolvers
  });

  cache.writeData<CarFormQuery>({
    data: {
      carForm: {
        __typename: "Car",
        brand: "",
        model: "",
        year: "",
        fastEnough: false
      }
    }
  });

  cache.writeData<CityFormQuery>({
    data: {
      cityForm: {
        __typename: "City",
        name: "",
        country: "",
        population: null
      }
    }
  });

  return client;
};

src/resolvers.ts

import {
  Resolvers,
  Car,
  CarFormQuery,
  City,
  CityFormQuery
} from "./graphql/types";
import { InMemoryCache } from "apollo-cache-inmemory";
import { Queries } from "./queries";

export const resolvers: Resolvers = {
  Query: {
    carForm: (_, args, { cache }: { cache: InMemoryCache }) => {
      const queryCarForm = cache.readQuery<Car>({
        query: Queries.QUERY_CAR
      });
      return queryCarForm;
    },
    cityForm: (_, args, { cache }: { cache: InMemoryCache }) => {
      const queryCityForm = cache.readQuery<City>({
        query: Queries.QUERY_CITY
      });
      return queryCityForm;
    }
  },
  Mutation: {
    persistCarForm: (
      _,
      { carFormInput },
      { cache }: { cache: InMemoryCache }
    ) => {
      const { brand, model, year, fastEnough } = carFormInput;

      cache.writeData<CarFormQuery>({
        data: {
          carForm: {
            __typename: "Car",
            brand,
            model,
            year,
            fastEnough
          }
        }
      });
      return "OK";
    },
    persistCityForm: (
      _,
      { cityFormInput },
      { cache }: { cache: InMemoryCache }
    ) => {
      const { name, country, population } = cityFormInput;

      cache.writeData<CityFormQuery>({
        data: {
          cityForm: {
            __typename: "City",
            name,
            country,
            population
          }
        }
      });
      return "OK";
    }
  }
};

In this sample app, we have no graphql server. We'll only use Apollo Cache for our forms data. So, ApolloProxy.ts has no link to a backend. We're creating default form data in ApolloCache carForm & cityForm. Notice, we're using typescript generics with the generated types CarFormQuery & CityFormQuery in cache write operations. We're totally type safe here. For instance, try to change the name property of the cityForm to cityName. Typescript compiler immediately complains and warns you.

In resolvers.ts, we're using Resolvers and other generated types by graphql-code-generator.

Now we're updating Cars.tsx and City.tsx to make use of newly generated types and the resolvers.ts we've just created.

src/pages/Cars.tsx

const Cars: React.FunctionComponent<ICars> = (props: ICars) => {
  const {
    data: {
      carForm: { __typename, ...noTypename }
    }
  } = useCarFormQuery();

  const [persistCarForm] = usePersistCarFormMutation();

  return (
    <Formik
      initialValues={noTypename}
      onSubmit={values => {
        persistCarForm({
          variables: {
            args: values
          }
        });
      }}
    >
      <CarForm />
    </Formik>
  );
};

src/pages/Cities.tsx

const Cities: React.FunctionComponent<ICities> = (props: ICities) => {
  const {
    data: {
      cityForm: { __typename, ...noTypename }
    }
  } = useCityFormQuery();

  const [persistCityForm] = usePersistCityFormMutation();
  return (
    <Formik
      initialValues={noTypename}
      onSubmit={values =>
        persistCityForm({
          variables: {
            args: values
          }
        })
      }
    >
      <CityForm />
    </Formik>
  );
};

We need to create and provide ApolloProvider, so that we can make use of useQuery and useMutation hooks in our pages. So, modify index.tsx;

src/index.tsx

import React from "react";
import ReactDOM from "react-dom";
import { ApolloProvider } from "@apollo/react-hooks";
import { getClient } from "./ApolloProxy";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";

const nodeserviceApolloClient = getClient();

ReactDOM.render(
  <ApolloProvider client={nodeserviceApolloClient}>
    <App />
  </ApolloProvider>,
  document.getElementById("root")
);
serviceWorker.unregister();

Now you should be all set. Try yarn start

Final words...

Although you can use useQuery and useMutaion hooks directly, I always prefer using the hooks generated by graphql-code-generator. Because, if we use string based queries directly as below;

const { data, error, loading } = useQuery<CarFormQuery>(Queries.QUERY_CAR);

We wont be warned in compile time against incorrect changes in our QUERY_CAR. On the other hand, if we stick to using generated hooks as follows;

const {
data: { carForm }
} = useCarFormQuery()

any incorrect query string would lead to generation time error. And we'd be warned very early.

Hope you enjoy ;-)

Happy coding...

@killjoy_tr

Discussion (0)