Skip to content

Instantly share code, notes, and snippets.

@willgriffiths
Forked from davidkpiano/reddit.md
Last active July 24, 2019 11:37
Show Gist options
  • Save willgriffiths/5a717f98527db7fce6a96a475ac0bb91 to your computer and use it in GitHub Desktop.
Save willgriffiths/5a717f98527db7fce6a96a475ac0bb91 to your computer and use it in GitHub Desktop.
XState + React + Reddit API Tutorial (Review)

Reddit API

Suppose we wanted to create an app that displays a selected subreddit's posts. Specifically, the app should be able to:

  • Have a predefined list of subreddits that the user can select from
  • Load the selected subreddit
  • Display the last time the selected subreddit was loaded
  • Reload the selected subreddit
  • Select a different subreddit at any time

The app logic and state can be modeled with a single app-level machine, as well as invoked child machines for modeling the logic of each individual subreddit. For now, let's start with a single machine.

Modeling the App

The Reddit app we're creating can be modeled with two top-level states:

  • 'idle' - no subreddit selected yet (the initial state)
  • 'selected' - a subreddit is selected
const redditMachine = Machine({
  id: 'reddit',
  initial: 'idle',
  states: {
    idle: {},
    selected: {}
  }
});

We also need somewhere to store the selected subreddit, so let's put that in context:

const redditMachine = Machine({
  id: 'reddit',
  initial: 'idle',
  context: {
    subreddit: null // none selected
  },
  states: {
    idle: {},
    selected: {}
  }
});

Since a subreddit can be selected at any time, we can create a top-level transition for a 'SELECT' event, which represents a subreddit being selected by the user. This event can have a payload that has a subreddit .name:

// sample SELECT event
const selectEvent = {
  type: 'SELECT', // event type
  name: 'reactjs' // subreddit name
};

This event will be handled at the top-level, so that whenever the 'SELECT' event occurs, the machine will:

  • transition to its child '.selected' state (notice the dot, which indicates a relative target)
  • assign context.subreddit to the event.name
const redditMachine = Machine({
  id: 'reddit',
  initial: 'idle',
  context: {
    subreddit: null // none selected
  },
  states: {
    /* ... */
  },
  on: {
    SELECT: {
      target: '.selected',
      actions: assign({
        subreddit: (context, event) => event.name
      })
    }
  }
});

Async Flow

When a subreddit is selected (that is, when the machine is in the 'selected' state due to a 'SELECT' event), the machine should start loading the subreddit data. To do this, we invoke a Promise that will resolve with the selected subreddit data:

const invokeFetchSubreddit = (context) => {
  const { subreddit } = context;

  return fetch(`https://www.reddit.com/r/${subreddit}.json`)
    .then(response => response.json())
    .then(json => json.data.children.map(child => child.data));
}

const redditMachine = Machine({
  /* ... */
  states: {
    idle: {},
    selected: {
      invoke: {
        id: 'fetch-subreddit',
        src: invokeFetchSubreddit
      }
    }
  },
  on: {/* ... */}
});

When the 'selected' state is entered, invokeFetchSubreddit(...) will be called with the current context and event (not used here) and start fetching subreddit data from the Reddit API. The promise can then take two special transitions:

  • onDone - taken when the invoked promise resolves
  • onError - taken when the invoked promise rejects

This is where it's helpful to have nested (hierarchical) states. We can make 3 child states that represent when the subreddit is 'loading', 'loaded' or 'failed' (pick names appropriate to your use-cases):

const redditMachine = Machine({
  /* ... */
  states: {
    idle: {},
    selected: {
      initial: 'loading',
      states: {
        loading: {
          invoke: {
            id: 'fetch-subreddit',
            src: invokeFetchSubreddit,
            onDone: 'loaded',
            onError: 'failed'
          }
        },
        loaded: {},
        failed: {}
      }
    }
  },
  on: {
    /* ... */
  }
});

Notice how we moved the invoke config to the 'loading' state. This is useful because if we want to change the app logic in the future to have some sort of 'paused' or 'canceled' child state, the invoked promise will automatically be "canceled" since it's no longer in the 'loading' state where it was invoked.

When the promise resolves, a special 'done.invoke.<invoke ID>' event will be sent to the machine, containing the resolved data as event.data. You can assign this data in context:

const redditMachine = Machine({
  /* ... */
  context: {
    subreddit: null,
    posts: null
  },
  states: {
    idle: {},
    selected: {
      initial: 'loading',
      states: {
        loading: {
          invoke: {
            id: 'fetch-subreddit',
            src: invokeFetchSubreddit,
            onDone: {
              target: 'loaded',
              actions: assign({
                posts: (context, event) => event.data
              })
            },
            onError: 'failed'
          }
        },
        loaded: {},
        failed: {}
      }
    }
  },
  on: {
    /* ... */
  }
});

Test It Out

Of course, it's a good idea to test that your machine's logic matches the app logic you intended. Since the machine.transition(...) function is just a pure reducer, you can test this logic by passing in events and ensuring that the resulting state matches the expected state:

describe('reddit machine', () => {
  it('should load posts of a selected subreddit', done => {
    let currentState = redditMachine.initialState;

    currentState = redditMachine.transition(currentState, {
      type: 'SELECT',
      name: 'reactjs'
    });

    assert.isTrue(currentState.matches('selected'));
    assert.equal(currentState.context.subreddit, 'reactjs');

    assert.isTrue(currentState.matches({ selected: 'loading' }));

    const mockPosts = [
      /* ... */
    ];

    currentState = redditMachine.transition(
      currentState,
      doneInvoke('fetch-subreddit', mockPosts) // What does it do and where does `doneInvoke` come from?
    );

    assert.isTrue(currentState.matches({ selected: 'loaded' }));
    assert.equal(currentState.context.posts, mockPosts);
  });
});

Implementing the UI

From here, your app logic is self-contained in the redditMachine and can be used however you want, in any front-end framework, such as React, Vue, Angular, Svelte, etc.

Here's an example of how it would be used in React with @xstate/react:

import React from 'react';
import { useMachine } from '@xstate/react';
import { redditMachine } from '../path/to/redditMachine';

const subreddits = ['frontend', 'reactjs', 'vuejs'];

const App = () => {
  const [current, send] = useMachine(redditMachine);
  const { subreddit, posts } = current.context;

  return (
    <main>
      <header>
        <select
          onChange={e => {
            send('SELECT', {name: e.target.value});
          }}
        >
          {subreddits.map(subreddit => {
            return <option key={subreddit}>{subreddit}</option>;
          })}
        </select>
      </header>
      <section>
        <h1>{current.matches('idle') ? 'Select a subreddit' : subreddit}</h1>
        {current.matches({ selected: 'loading' }) && <div>Loading...</div>}
        {current.matches({ selected: 'loaded' }) && (
          <ul>
            {posts.map(post => (
              <li key={post.title}>{post.title}</li>
            ))}
          </ul>
        )}
      </section>
    </main>
  );
};

Splitting Machines

Within the chosen UI framework, components provide natural isolation and encapsulation of logic. We can take advantage of that to organize logic and make smaller, more manageable machines.

Consider two machines:

  • A redditMachine, which is the app-level machine, responsible for rendering the selected subreddit component
  • A subredditMachine, which is the machine responsible for loading and displaying its specified subreddit
const createSubredditMachine = subreddit => {
  return Machine({
    id: 'subreddit',
    initial: 'loading',
    context: {
      subreddit, // subreddit name passed in
      posts: null,
      lastUpdated: null
    },
    states: {
      loading: {
        invoke: {
          id: 'fetch-subreddit',
          src: invokeFetchSubreddit,
          onDone: {
            target: 'loaded',
            actions: assign({
              posts: (_, event) => event.data,
              lastUpdated: () => Date.now()
            })
          }
        }
      },
      loaded: {
        on: {
          REFRESH: 'loading'
        }
      },
      failure: {}
    }
  });
};

Notice how a lot of the logic in the original redditMachine was moved to the subredditMachine. That allows us to isolate logic to their specific domains and make the redditMachine more general, without being concerned with subreddit loading logic:

const redditMachine = Machine({
  id: 'reddit',
  initial: 'idle',
  context: {
    subreddit: null
  },
  states: {
    idle: {},
    selected: {} // no invocations!
  },
  on: {
    SELECT: {
      target: '.selected',
      actions: assign({
        subreddit: (context, event) => event.name
      })
    }
  }
});

Then, in the UI framework (React, in this case), a <Subreddit> component can be responsible for displaying the subreddit, using the logic from the created subredditMachine:

const Subreddit = ({ name }) => {
  const subredditMachine = useMemo(() => createSubredditMachine(name), [name]); // This one confused me for a little bit. :)

  const [current, send] = useMachine(subredditMachine);

  if (current.matches('loading')) {
    return <div>Loading posts...</div>;
  }

  if (current.matches('failure')) {
    return <div>Failed to load posts.</div>;
  }

  const { subreddit, posts, lastUpdated } = current.context;

  return (
    <div>
      <h2>{subreddit}</h2>
      <strong>Last updated: {lastUpdated}</strong>
      <button onClick={_ => send('REFRESH')}>Refresh</button>
      <ul>
        {posts.map(post => {
          return <li key={post.id}>{post.title}</li>;
        })}
      </ul>
    </div>
  );
};

And the overall app can use that <Subreddit> component:

const App = () => {
  const [current, send] = useMachine(redditMachine);
  const { subreddit } = current.context;

  return (
    <main>
      {/* ... */}
      {subreddit && <Subreddit name={subreddit} key={subreddit} />} // The current implementation crashes  when subreddit is `null`.
    </main>
  );
};

Advanced: Using Actors

The machines we've created work, and fit our basic use-cases. However, suppose we want to support the following use-cases:

  • When a subreddit is selected, it should load fully, even if a different one is selected (basic "caching")
  • The user should see when a subreddit was last updated, and have the ability to refresh the subreddit.

A good mental model for this is the Actor model, where each individual subreddit is its own "actor" that controls its own logic based on events, whether internal or external.

Spawning Subreddit Actors

Recall that an actor is an entity that has its own logic/behavior, and it can receive and send events to other actors.

graph TD; A("subreddit (reactjs)") B("subreddit (vuejs)") C("subreddit (frontend)") reddit-.->A; reddit-.->B; reddit-.->C;

The context of the redditMachine needs to be modeled to:

  • maintain a mapping of subreddits to their spawned actors
  • keep track of which subreddit is currently visible
const redditMachine = Machine({
  // ...
  context: {
    subreddits: {},
    subreddit: null
  }
  // ...
});

When a subreddit is selected, one of two things can happen:

  1. If that subreddit actor already exists in the context.subreddits object, assign() it as the current context.subreddit.
  2. Otherwise, spawn() a new subreddit actor with subreddit machine behavior from createSubredditMachine, assign it as the current context.subreddit, and save it in the context.subreddits object.
const redditMachine = Machine({
  // ...
  context: {
    subreddits: {},
    subreddit: null
  },
  // ...
  on: {
    SELECT: [
      // Use the existing subreddit actor if one doesn't exist
      {
        target: '.selected',
        actions: assign({
          subreddit: (context, event) => context.subreddits[event.name]
        }),
        cond: (context, event) => context.subreddits[event.name] !== undefined
      },
      // Otherwise, spawn a new subreddit actor and
      // save it in the subreddits object
      {
        target: '.selected',
        actions: [
          assign({
            subreddit: (context, event) =>
              spawn(createSubredditMachine(event.name))
          }),
          assign({
            subreddits: (context, event) => ({
              ...context.subreddits,
              [event.name]: context.subreddit
            })
          })
        ]
      }
    ]
  }
});

Putting It All Together

Now that we have each subreddit encapsulated in its own "live" actor with its own logic and behavior, we can pass these actor references (or "refs") around as data. These actors created from machines are called "services" in XState. Just like any actor, events can be sent to these services, but these services can also be subscribed to. The subscriber will receive the most current state of the service whenever it's updated.

::: tip In React, change detection is done by reference, and changes to props/state cause rerenders. An actor's reference never changes, but its internal state may change. This makes actors ideal for when top-level state needs to maintain references to spawned actors, but should not rerender when a spawned actor changes (unless explicitly told to do so via an event sent to the parent).

In other words, spawned child actors updating will not cause rerenders. 🎉 :::

// ./Subreddit.jsx

const Subreddit = ({ service }) => {
  const [current, send] = useService(service);

  // ... same code as previous Subreddit component
};
// ./App.jsx

const App = () => {
  const [current, send] = useMachine(redditMachine);
  const { subreddit } = current.context;

  return (
    <main>
      {/* ... */}
      {subreddit && <Subreddit service={subreddit} key={subreddit.id} />} // The current implementation crashes when subreddit is `null`.

    </main>
  );
};

The differences between using the actor model above and just using machines with a component hierarchy (e.g., with React) are:

  • The data flow and logic hierarchy live in the XState services, not in the components. This is important when the subreddit needs to continue loading, even when its <Subreddit> component may be unmounted.
  • The UI framework layer (e.g., React) becomes a plain view layer; logic and side-effects are not tied directly to the UI, except where it is appropriate.
  • The redditMachine -> subredditMachine actor hierarchy is "self-sustaining", and allows for the logic to be transferred to any UI framework, or even no framework at all!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment