# Reddit API
Adapted from the Redux Docs: Advanced Tutorial (opens new window)
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
import { createMachine, assign } from 'xstate';
const redditMachine = createMachine({
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 = createMachine({
id: 'reddit',
initial: 'idle',
context: {
subreddit: null // none selected
},
states: {
/* ... */
}
});
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
:
// 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
event.name
to thecontext.subreddit
const redditMachine = createMachine({
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:
function 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 = createMachine({
/* ... */
states: {
idle: {},
selected: {
invoke: {
id: 'fetch-subreddit',
src: invokeFetchSubreddit
}
}
},
on: {
/* ... */
}
});
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 resolvesonError
- 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 = createMachine({
/* ... */
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 'xstate.done.actor.<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
:
const redditMachine = createMachine({
/* ... */
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: {
/* ... */
}
});
# 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:
import { interpret } from 'xstate';
import { assert } from 'chai';
import { redditMachine } from '../path/to/redditMachine';
describe('reddit machine (live)', () => {
it('should load posts of a selected subreddit', (done) => {
const redditService = interpret(redditMachine)
.onTransition((state) => {
// when the state finally reaches 'selected.loaded',
// the test has succeeded.
if (state.matches({ selected: 'loaded' })) {
assert.isNotEmpty(state.context.posts);
done();
}
})
.start(); // remember to start the service!
// Test that when the 'SELECT' event is sent, the machine eventually
// reaches the { selected: 'loaded' } state with posts
redditService.send({ type: 'SELECT', name: 'reactjs' });
});
});
# 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({ type: '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 createMachine({
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()
})
},
onError: 'failure'
}
},
loaded: {
on: {
REFRESH: 'loading'
}
},
failure: {
on: {
RETRY: 'loading'
}
}
}
});
};
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 = createMachine({
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 }) => {
// Only create the machine based on the subreddit name once
const subredditMachine = useMemo(() => {
return createSubredditMachine(name);
}, [name]);
const [current, send] = useMachine(subredditMachine);
if (current.matches('failure')) {
return (
<div>
Failed to load posts.{' '}
<button onClick={(_) => send('RETRY')}>Retry?</button>
</div>
);
}
const { subreddit, posts, lastUpdated } = current.context;
return (
<section
data-machine={subredditMachine.id}
data-state={current.toStrings().join(' ')}
>
{current.matches('loading') && <div>Loading posts...</div>}
{posts && (
<>
<header>
<h2>{subreddit}</h2>
<small>
Last updated: {lastUpdated}{' '}
<button onClick={(_) => send('REFRESH')}>Refresh</button>
</small>
</header>
<ul>
{posts.map((post) => {
return <li key={post.id}>{post.title}</li>;
})}
</ul>
</>
)}
</section>
);
};
And the overall app can use that <Subreddit>
component:
const App = () => {
const [current, send] = useMachine(redditMachine);
const { subreddit } = current.context;
return (
<main>
<header>{/* ... */}</header>
{subreddit && <Subreddit name={subreddit} key={subreddit} />}
</main>
);
};
# 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.
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 = createMachine({
// ...
context: {
subreddits: {},
subreddit: null
}
// ...
});
When a subreddit is selected, one of two things can happen:
- If that subreddit actor already exists in the
context.subreddits
object,assign()
it as the currentcontext.subreddit
. - Otherwise,
spawn()
a new subreddit actor with subreddit machine behavior fromcreateSubredditMachine
, assign it as the currentcontext.subreddit
, and save it in thecontext.subreddits
object.
const redditMachine = createMachine({
// ...
context: {
subreddits: {},
subreddit: null
},
// ...
on: {
SELECT: {
target: '.selected',
actions: assign((context, event) => {
// Use the existing subreddit actor if one already exists
let subreddit = context.subreddits[event.name];
if (subreddit) {
return {
...context,
subreddit
};
}
// Otherwise, spawn a new subreddit actor and
// save it in the subreddits object
subreddit = spawn(createSubredditMachine(event.name));
return {
subreddits: {
...context.subreddits,
[event.name]: subreddit
},
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.
提示
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. 🎉
// ./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} />}
</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!
# 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 (opens new window)):
← SCXML Task 1: Counter →