Reddit API

Adapted from the Redux Docs: Advanced Tutorial

Suppose we wanted to create an app that displays a selected subreddit’s posts. 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

```js {6-9} import { createMachine, assign } from ‘xstate’;

const redditMachine = createMachine({ id: ‘reddit’, initial: ‘idle’, states: { idle: {}, selected: {} } });

  1. We also need somewhere to store the selected `subreddit`, so let's put that in [`context`](../guides/context.md):
  2. ```js {6-8}
  3. // ...
  4. const redditMachine = createMachine({
  5. id: 'reddit',
  6. initial: 'idle',
  7. context: {
  8. subreddit: null // none selected
  9. },
  10. states: {
  11. /* ... */
  12. }
  13. });

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

  1. // sample SELECT event
  2. const selectEvent = {
  3. type: 'SELECT', // event type
  4. name: 'reactjs' // subreddit name
  5. };

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

```js {10-17} const redditMachine = createMachine({ id: ‘reddit’, initial: ‘idle’, context: { subreddit: null // none selected }, states: { // }, on: { SELECT: { target: ‘.selected’, actions: assign({ subreddit: (context, event) => event.name }) } } });

  1. ## Async Flow
  2. 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](../guides/communication.html#invoking-promises) that will resolve with the selected subreddit data:
  3. ```js {1-7,14-17}
  4. function invokeFetchSubreddit(context) {
  5. const { subreddit } = context;
  6. return fetch(`https://www.reddit.com/r/${subreddit}.json`)
  7. .then((response) => response.json())
  8. .then((json) => json.data.children.map((child) => child.data));
  9. }
  10. const redditMachine = createMachine({
  11. /* ... */
  12. states: {
  13. idle: {},
  14. selected: {
  15. invoke: {
  16. id: 'fetch-subreddit',
  17. src: invokeFetchSubreddit
  18. }
  19. }
  20. },
  21. on: {
  22. /* ... */
  23. }
  24. });
Why specify the invoke ID? Specifying an id on the invoke config object allows clearer debugging and visualization, as well as the ability to send events directly to an invoked entity by its id.

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):

```js {8-17} const redditMachine = createMachine({ // states: { idle: {}, selected: { initial: ‘loading’, states: { loading: { invoke: { id: ‘fetch-subreddit’, src: invokeFetchSubreddit, onDone: ‘loaded’, onError: ‘failed’ } }, loaded: {}, failed: {} } } }, on: { // } });

  1. 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.
  2. When the promise resolves, a special `'done.invoke.<invoke ID>'` event will be sent to the machine, containing the resolved data as `event.data`. For convenience, XState maps the `onDone` property within the `invoke` object to this special event. You can assign the resolved data to `context.posts`:
  3. ```js {18-20}
  4. const redditMachine = createMachine({
  5. /* ... */
  6. context: {
  7. subreddit: null,
  8. posts: null
  9. },
  10. states: {
  11. idle: {},
  12. selected: {
  13. initial: 'loading',
  14. states: {
  15. loading: {
  16. invoke: {
  17. id: 'fetch-subreddit',
  18. src: invokeFetchSubreddit,
  19. onDone: {
  20. target: 'loaded',
  21. actions: assign({
  22. posts: (context, event) => event.data
  23. })
  24. },
  25. onError: 'failed'
  26. }
  27. },
  28. loaded: {},
  29. failed: {}
  30. }
  31. }
  32. },
  33. on: {
  34. /* ... */
  35. }
  36. });

Testing It Out

It’s a good idea to test that your machine’s logic matches the app logic you intended. The most straightforward way to confidently test your app logic is by writing integration tests. You can test against a real or mock implementation of your app logic (e.g., using real services, making API calls, etc.), you can run the logic in an interpreter via interpret(...) and write an async test that finishes when the state machine reaches a certain state:

  1. import { interpret } from 'xstate';
  2. import { assert } from 'chai';
  3. import { redditMachine } from '../path/to/redditMachine';
  4. describe('reddit machine (live)', () => {
  5. it('should load posts of a selected subreddit', (done) => {
  6. const redditService = interpret(redditMachine)
  7. .onTransition((state) => {
  8. // when the state finally reaches 'selected.loaded',
  9. // the test has succeeded.
  10. if (state.matches({ selected: 'loaded' })) {
  11. assert.isNotEmpty(state.context.posts);
  12. done();
  13. }
  14. })
  15. .start(); // remember to start the service!
  16. // Test that when the 'SELECT' event is sent, the machine eventually
  17. // reaches the { selected: 'loaded' } state with posts
  18. redditService.send('SELECT', { name: 'reactjs' });
  19. });
  20. });

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:

  1. import React from 'react';
  2. import { useMachine } from '@xstate/react';
  3. import { redditMachine } from '../path/to/redditMachine';
  4. const subreddits = ['frontend', 'reactjs', 'vuejs'];
  5. const App = () => {
  6. const [current, send] = useMachine(redditMachine);
  7. const { subreddit, posts } = current.context;
  8. return (
  9. <main>
  10. <header>
  11. <select
  12. onChange={(e) => {
  13. send('SELECT', { name: e.target.value });
  14. }}
  15. >
  16. {subreddits.map((subreddit) => {
  17. return <option key={subreddit}>{subreddit}</option>;
  18. })}
  19. </select>
  20. </header>
  21. <section>
  22. <h1>{current.matches('idle') ? 'Select a subreddit' : subreddit}</h1>
  23. {current.matches({ selected: 'loading' }) && <div>Loading...</div>}
  24. {current.matches({ selected: 'loaded' }) && (
  25. <ul>
  26. {posts.map((post) => (
  27. <li key={post.title}>{post.title}</li>
  28. ))}
  29. </ul>
  30. )}
  31. </section>
  32. </main>
  33. );
  34. };

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
  1. const createSubredditMachine = (subreddit) => {
  2. return createMachine({
  3. id: 'subreddit',
  4. initial: 'loading',
  5. context: {
  6. subreddit, // subreddit name passed in
  7. posts: null,
  8. lastUpdated: null
  9. },
  10. states: {
  11. loading: {
  12. invoke: {
  13. id: 'fetch-subreddit',
  14. src: invokeFetchSubreddit,
  15. onDone: {
  16. target: 'loaded',
  17. actions: assign({
  18. posts: (_, event) => event.data,
  19. lastUpdated: () => Date.now()
  20. })
  21. },
  22. onError: 'failure'
  23. }
  24. },
  25. loaded: {
  26. on: {
  27. REFRESH: 'loading'
  28. }
  29. },
  30. failure: {
  31. on: {
  32. RETRY: 'loading'
  33. }
  34. }
  35. }
  36. });
  37. };

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:

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

  1. 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`:
  2. ```jsx
  3. const Subreddit = ({ name }) => {
  4. // Only create the machine based on the subreddit name once
  5. const subredditMachine = useMemo(() => {
  6. return createSubredditMachine(name);
  7. }, [name]);
  8. const [current, send] = useMachine(subredditMachine);
  9. if (current.matches('failure')) {
  10. return (
  11. <div>
  12. Failed to load posts.{' '}
  13. <button onClick={(_) => send('RETRY')}>Retry?</button>
  14. </div>
  15. );
  16. }
  17. const { subreddit, posts, lastUpdated } = current.context;
  18. return (
  19. <section
  20. data-machine={subredditMachine.id}
  21. data-state={current.toStrings().join(' ')}
  22. >
  23. {current.matches('loading') && <div>Loading posts...</div>}
  24. {posts && (
  25. <>
  26. <header>
  27. <h2>{subreddit}</h2>
  28. <small>
  29. Last updated: {lastUpdated}{' '}
  30. <button onClick={(_) => send('REFRESH')}>Refresh</button>
  31. </small>
  32. </header>
  33. <ul>
  34. {posts.map((post) => {
  35. return <li key={post.id}>{post.title}</li>;
  36. })}
  37. </ul>
  38. </>
  39. )}
  40. </section>
  41. );
  42. };

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

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

return (

{//}
{subreddit && }
); };

  1. ## Using Actors
  2. The machines we've created work, and fit our basic use-cases. However, suppose we want to support the following use-cases:
  3. - When a subreddit is selected, it should load fully, even if a different one is selected (basic "caching")
  4. - The user should see when a subreddit was last updated, and have the ability to refresh the subreddit.
  5. A good mental model for this is the [Actor model](../guides/actors.md), where each individual subreddit is its own "actor" that controls its own logic based on events, whether internal or external.
  6. ## Spawning Subreddit Actors
  7. Recall that an actor is an entity that has its own logic/behavior, and it can receive and send events to other actors.
  8. <mermaid>
  9. graph TD;
  10. A("subreddit (reactjs)")
  11. B("subreddit (vuejs)")
  12. C("subreddit (frontend)")
  13. reddit-.->A;
  14. reddit-.->B;
  15. reddit-.->C;
  16. </mermaid>
  17. The `context` of the `redditMachine` needs to be modeled to:
  18. - maintain a mapping of subreddits to their spawned actors
  19. - keep track of which subreddit is currently visible
  20. ```js {4,5}
  21. const redditMachine = createMachine({
  22. // ...
  23. context: {
  24. subreddits: {},
  25. subreddit: null
  26. }
  27. // ...
  28. });

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.
  1. const redditMachine = createMachine({
  2. // ...
  3. context: {
  4. subreddits: {},
  5. subreddit: null
  6. },
  7. // ...
  8. on: {
  9. SELECT: {
  10. target: '.selected',
  11. actions: assign((context, event) => {
  12. // Use the existing subreddit actor if one already exists
  13. let subreddit = context.subreddits[event.name];
  14. if (subreddit) {
  15. return {
  16. ...context,
  17. subreddit
  18. };
  19. }
  20. // Otherwise, spawn a new subreddit actor and
  21. // save it in the subreddits object
  22. subreddit = spawn(createSubredditMachine(event.name));
  23. return {
  24. subreddits: {
  25. ...context.subreddits,
  26. [event.name]: subreddit
  27. },
  28. subreddit
  29. };
  30. })
  31. }
  32. }
  33. });

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 unnecessary rerenders. 🎉 :::

  1. // ./Subreddit.jsx
  2. const Subreddit = ({ service }) => {
  3. const [current, send] = useService(service);
  4. // ... same code as previous Subreddit component
  5. };
  1. // ./App.jsx
  2. const App = () => {
  3. const [current, send] = useMachine(redditMachine);
  4. const { subreddit } = current.context;
  5. return (
  6. <main>
  7. {/* ... */}
  8. {subreddit && <Subreddit service={subreddit} key={subreddit.id} />}
  9. </main>
  10. );
  11. };

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 redditMachinesubredditMachine actor hierarchy is “self-sustaining”, and allows for the logic to be transferred to any UI framework, or even no framework at all!

React Demo

Vue Demo

Unsurprisingly, the same machines can be used in a Vue app that exhibits the exact same behavior (thanks to Chris Hannaby):