Mocking and Using CRUD APIs with MirageJS and React-Query

Two recent releases have advanced the state of the art for mocking and using APIs in React apps. I think they pair very well and this post describes what they do and how to use them together. I don't have the space or time to go into the full features of each, which are the actual reasons they deliver great value - those are food for future posts.

Demo

This code runs best in development (since the focus is mocking APIs for development): https://github.com/sw-yx/react-query-miragejs-demo

reactquery2

Mirage JS

Mirage JS describes itself as an API mocking library that lets you build, test and share a complete working JavaScript application without having to rely on any backend services.

It was previously used in the Ember ecosystem, and has recently been split out to be a general purpose framework agnostic API mocking tool. Here is how you install it:

yarn add --dev miragejs

If you care about TypeScript, you can check out https://github.com/zoltan-nz/miragejs/blob/master/types/index.d.ts, however I had some trouble actually using it.

Here's how it breaks down its concepts in the Intro:

  • routes to handle HTTP requests
  • a database and models for storing data and defining relationships
  • factories and fixtures for stubbing data, and
  • serializers for formatting HTTP responses

These are all things I've had to write for testing - now there's a proper framework that does this for testing AND for local dev!

Setting up a basic API

Now let's set up a basic React app that magically responds to an API while in development:

// index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import "./index.css";
import { Server } from 'miragejs';

new Server({
  routes() {
    this.namespace = 'api';

    this.get('/movies', () => {
      return [
          { id: 1, name: 'Inception', year: 2010 },
          { id: 2, name: 'Interstellar', year: 2014 },
          { id: 3, name: 'Dunkirk', year: 2017 }
      ]
    });
  }
});

ReactDOM.render(<App />, document.getElementById("app"));

and we can use freely ping it from our frontend:

import React from 'react';

export default function App() {
  const [data, setData] = React.useState(null);
  React.useEffect(() => {
    fetch('/api/movies')
      .then((x) => x.json())
      .then(setData);
  }, []);
  return (
    <div>
      <div>
        <div>
          {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
        </div>
      </div>
    </div>
  );
}

reactquery1

Wow. It just works despite not actually having a backend to ping!

React Query

React Query describes itself as "Hooks for fetching, caching and updating asynchronous data in React". If this makes you think of React-async or Redux-thunk, you're thinking at respectively too low and too high levels of abstraction. I'll expand on this in a future blogpost.

yarn add react-query

as at time of writing, TypeScript types were only matching the v0.3 API, but there were some APIs that were changed for the v1.0 launch and you can get my tweaks here: https://gist.github.com/sw-yx/1c9428a30f87f678c4fba0a2fd45a47d

Here are a quick list of it's great features from the docs:

  • Transport/protocol/backend agnostic data fetching (REST, GraphQL, promises, whatever!)
  • Auto Caching + Refetching (stale-while-revalidate, Window Refocus, Polling/Realtime)
  • Parallel + Dependent Queries
  • Mutations + Reactive Query Refetching
  • Multi-layer Cache + Automatic Garbage Collection
  • Paginated + Cursor-based Queries
  • Load-More + Infinite Scroll Queries w/ Scroll Recovery
  • Request Cancellation
  • React Suspense + Fetch-As-You-Render Query Prefetching

Alright. How does React-Query change how we data fetch?

import React from 'react';
import { useQuery } from 'react-query';

type Data = { id: number; name: string; year: number };
export default function App() {
  const { status, data, error } = useQuery<Data[], any>('movies', () =>
    fetch('/api/movies').then((x) => x.json())
  );
  return (
    <div>
      <div>
        <div>{status}</div>
        {error && <div>{error}</div>}
        <div>
          {status === 'loading' ? (
            <span>Loading...</span>
          ) : status === 'error' ? (
            <span>Error: {error!.message}</span>
          ) : (
            <ul>
              {data!.map((movie) => (
                <li key={movie.id}>
                  {movie.name} ({movie.year})
                </li>
              ))}
            </ul>
          )}
        </div>
      </div>
    </div>
  );
}

Wow, so everything becomes a good deal more declarative and loading and error states are deal with for us. Great! exactly like react-async.

Mocking CRUD with Mirage JS

Mirage doesn't just spit back static data. You can simulate latency and CRUD to a pretty high fidelity! Let's evolve our mocking to show todo list:

// etc..
import { Server, Model } from 'miragejs';
new Server({
    models: {
      todo: Model
    },
    seeds(server) {
      server.create('todo', { text: 'Learn Mirage' } as any);
      server.create('todo', { text: 'Shake it off', isDone: true } as any);
      server.create('todo', { text: 'Profit' } as any);
    },
    routes() {
      this.namespace = 'api';
      this.timing = 750;
      this.get('/todos', (schema: any) => {
        return schema.todos.all(); // persistent even after navigating away
      });
      this.post('/todos', (schema: any, request) => {
        const attrs = JSON.parse(request.requestBody);
        return schema.todos.create(attrs);
      });
      this.patch('/todos/:id', (schema, request) => {
        let todo = JSON.parse(request.requestBody);
        return schema.db.todos.update(todo.id, todo);
      });
    }
  });

ReactDOM.render(
  <Router><App /></Router>, document.getElementById("app"));

So it offers some helpers to do Create and Update (patch). I didn't bother to implement Delete but you get the picture.

You can now build a frontend against your Mirage-mocked API:

import React, { useState } from 'react';
import { useQuery } from 'react-query';
// https://github.com/miragejs/react-demo/blob/master/src/components/Todos.js
type TodoType = {
  text: string,
  isDone: boolean,
  id?: string
}

export default function Todos() {
  const { status, data, refetch } = useQuery<TodoType[], any>('todos', () =>
    fetch('/api/todos')
      .then((res) => res.json())
      .then((json) => json.todos)
  );
  let todos = data || []
  let done = todos.filter((todo) => todo.isDone).length;

  async function createTodo(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const textField = event.target['newTodoName'];
    
    await fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify({ text: textField.value })
    })
      .then((res) => res.json())
      .then(refetch)
      .then(() => void(textField.value = ''));
  }

  async function saveTodo(todo: TodoType) {
    await fetch(`/api/todos/${todo.id}`, {
      method: 'PATCH',
      body: JSON.stringify(todo)
    }).then(() => refetch())
  }


  // console.log({ todos });
  return (
    <div className='max-w-sm px-4 py-6 mx-auto bg-white rounded shadow-lg'>
      <div className='flex items-center justify-between px-3'>
        <h1 className='text-2xl font-bold'>Todos</h1>

        <div className='text-blue-500'>
          {status === 'loading' && (
            <svg
              className='w-4 h-4 fill-current'
              viewBox='0 0 20 20'
              data-testid='saving'
            >
              <path d='M16.88 9.1A4 4 0 0 1 16 17H5a5 5 0 0 1-1-9.9V7a3 3 0 0 1 4.52-2.59A4.98 4.98 0 0 1 17 8c0 .38-.04.74-.12 1.1z' />
            </svg>
          )}
        </div>
      </div>

      <div className='mt-6'>
        {status === 'loading' ? (
          <p className='px-3 text-gray-500' data-testid='loading'>
            Loading...
          </p>
        ) : (
          <div>
            <div className='px-3'>
              <form onSubmit={createTodo} data-testid='new-todo-form'>
                <input
                  type='text'
                  name="newTodoName"
                  placeholder='New todo'
                  className='block w-full px-3 py-2 placeholder-gray-500 bg-white rounded shadow focus:outline-none'
                />
              </form>
            </div>

            {todos.length > 0 ? (
              <ul className='mt-8'>
                {todos.map((todo) => (
                  <Todo todo={todo} onChange={() => saveTodo(todo)} key={todo.id} />
                ))}
              </ul>
            ) : (
              <p
                className='px-3 mt-16 text-lg text-center text-gray-500'
                data-testid='no-todos'
              >
                Everything's done!
              </p>
            )}

            <div className='flex justify-between px-3 mt-12 text-sm font-medium text-gray-500'>
              {todos.length > 0 ? (
                <p>
                  {done} / {todos.length} complete
                </p>
              ) : null}
              {/* {done > 0 ? (
                <button
                  onClick={deleteCompleted}
                  className='font-medium text-blue-500 focus:outline-none focus:text-blue-300'
                >
                  Clear completed
                </button>
              ) : null} */}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}


function Todo({
  todo,
  onChange
}: {
  todo: TodoType;
  onChange: ((event: React.ChangeEvent<HTMLInputElement>) => void) | undefined;
}) {
  let [isFocused, setIsFocused] = useState(false);
  const handleSubmit = () => {
    console.log('handleSubmit')
    // onChange()
  }
  return (
    <li
      className={`
        my-1 rounded focus:bg-white border-2 flex items-center relative
        ${isFocused ? 'bg-white border-gray-300' : ''}
        ${!isFocused ? 'border-transparent hover:bg-gray-200' : ''}
        ${!isFocused && todo.isDone ? 'opacity-50' : ''}
      `}
      data-testid='todo'
    >
      <input
        type='checkbox'
        checked={todo.isDone}
        onChange={onChange}
        className='ml-2'
      />

      <form onSubmit={handleSubmit} className='relative w-full'>
        <input
          type='text'
          value={todo.text}
          onChange={onChange}
          placeholder='New Todo'
          onFocus={() => setIsFocused(true)}
          onBlur={onChange}
          className={`
            bg-transparent focus:outline-none px-3 py-1 block w-full
            ${todo.isDone && !isFocused ? 'line-through' : ''}
          `}
        />
      </form>
    </li>
  );
}

And we get:

reactquery2

Well, that was 166 lines of code without even implementing async state tracking. Can we do better?

Making CRUD with React-Query

Similar to how the GraphQL world thinks about reading and interacting with data, you can do CRUD with useMutation of React Query. Let's change createTodo to use it:

  const [postTodo, { status: postStatus }] = useMutation(async (value) =>
    fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify(value)
    })
      .then((res) => res.json())
      .then(refetch)
  );
  async function createTodo(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const textField = event.target['newTodoName'];

    await postTodo({ text: textField.value }).then(
      () => void (textField.value = '')
    );
  }

That's great, but what did we really gain from the rewrite? Well, we get acces to all these other handy APIs:

const [mutate, { status, data, error }] = useMutation(mutationFn, {
  onSuccess,
  onSettled,
  onError,
  throwOnError,
  useErrorBoundary,
})

const promise = mutate(variables, {
  onSuccess,
  onSettled,
  onError,
  throwOnError,
})

This is super handy for controlling where to relay async status to your UI, and also to add callbacks for when certain events happen.

This callback stuff is so handy that I can even move my refetch code in there:

  const [postTodo, { status: postStatus }] = useMutation(
    async (value) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(value)
      })
        .then((res) => res.json())
        .then(refetch),
    {
      onSuccess: () => {
        queryCache.refetchQueries('todos');
        // other cache invalidation queries and state updates
      }
    }
  );

Conclusion

If you liked this, let me know what else I should explore, as I think I am only scratching the surface with what's possible with both libraries. But all in all, this is a pretty powerful pairing of tools to rapidly create CRUD frontends in React.


Webmentions

Failed to load...