You can play and inspect the machine on CodeSandbox.
Available scripts:
npm start
to play with the Appnpm test
to run the machine' tests
First of all, why XState: even if I'm not an XState expert yet, I chose it because I think it's a superior tool to manage, read, and document Finite State Machines, compared to reading "long" useReducer
code or jumping through React hooks trying to understand what every useEffect
does. I think that's David Khourshid's "Redux is half of a pattern" is good reading for this topic. I got inspired by this article and by my willingness to gain experience with XState, to understand if I/we like the development pattern XState introduces carefully. Please note that we recently started using it at work, and I'm currently driving an XState Working Group.
Here is the full code of the Finite State Machine.
The design patterns used in the code of the Finite State Machine are:
- the machine' initial state is
idle
: initially, the People must be fetched to fill the list, and it will happen when the People page mount. Anyway, moving the machine from its initial "idle" state to fetching the People is a controlled event that must be sent by the People page itself, avoiding the machine to deliberately start fetching in a moment the app doesn't expect it to do.
export const machine = createMachine<Context, Events, States>(
{
initial: 'idle',
// ...
}
-
the events are uppercased: more, events are the only entity with an upper-cased name, to distinguish them from the rest of the entities.
-
preferring readability over smartness for transitions: the
SET_QUERY
/SET_EMPLOYMENT
transitions are repeated for many states (less than five times, though). This is my choice, preferring the reader to immediately understand the managed events for each state, instead of jumping around the code because of an unnecessary abstraction. -
avoiding inline callbacks and services: callbacks and services can be invoked inline or moved to the machine's configuration object (the second
createMachine
parameter). This is primarily important for testing purposes, allowing the tests to override the configuration through themachine.withConfig
function, instead of mocking the imported services (fetchPeople
, in this case).
export const machine = createMachine<Context, Events, States>(
{
/* ... */
},
{
services: {
// <-- NOTE: Services goe here
// ...
},
},
)
- avoiding inline delays: the previous point is valid for the debounce delay too, if in the future the machine's delay changes, nothing changes for the tests, which override the debounce delay.
export const machine = createMachine<Context, Events, States>(
{
/* ... */
},
{
delays: {
// <-- NOTE: Delays goe here
// ...
},
},
)
Here is the full code of the Finite State Machine.
Here are the full type definitions.
The design patterns used for the Finite State Machine' types are:
- every context includes only its specific overrides: the machine' context always has all the properties, but the various
InitialContext
,DebounceFetchContext
, etc. types specify only their specific content. They're not meant to be used isolated, but always through aContext & InitialContext
-like intersection.
export type InitialContext = {
filter: Filter
debounceFilter: undefined
fetching: false
people: []
fetchErrors: []
}
export type DebounceFetchContext = {
debounceFilter: Filter // <-- NOTE: Specify only the properties to override
}
export type States =
| { value: 'idle'; context: Context & InitialContext }
| { value: 'success'; context: Context & SuccessContext }
| { value: 'fetch'; context: Context & FetchContext }
| { value: 'debounceFetch'; context: Context & DebounceFetchContext } // <-- NOTE: the `Context & DebounceFetchContext` type
| { value: 'failure'; context: Context & FailureContext }
- event categories: in the
types
module, the events are categorized based on- the internal events, especially the ones that bring data
- the external events triggered directly by the user
- the external events not triggered directly by the user (ex. the
START
event that moves the machine away from its idle state)
This allows the reader to immediately get an idea of what are the events thought for
// ---------------------------------------------------------------
// EVENTS
export type Events = InternalEvents | ExternalEvents | UserEvents
Here is the full code of the tests.
The design patterns used for the tests of the Finite State Machine are:
- strong readability: I care a lot about tests' readability because they're the tools used to understand how the code works, and they must have a complexity level 10x lower than the code under test. The topic is quite subjective, obviously, but I tried my best to ease the future developers that
- want to understand what the machine does to refactor it
- need to fix the tests that are unexpectedly failing
The latter case is the most problematic. Usually, I tried to get the tests as clear as possible to ease the work of the fixing-tests developer that needs an immediate idea of how the tests work to fix them as fast as possible.
-
local mock data: this point is strictly related to the "readability" one. If the mock data are simple, they must stay in the same file to avoid external dependencies (take a look at
defaultFetchData
inmachine.test.ts
). -
linear testing flow and internal events: the Testing services chapter of the Testing Machines docs pushes the developer to test a service-based machine like the following
it('should eventually reach "success"', done => {
const service = interpret(machine).onTransition(state => {
if (state.matches('success')) {
done() // <-- NOTE: asserting/succeeding first
}
})
service.start()
service.send({
type: 'SUCESSS',
people: [
/*...*/
],
}) // <-- NOTE: acting last
})
but:
- this worsen the readability because commonly, the tests have the assertion/success as the last step
- at the same time, the
{ type: 'SUCESSS', people: [/*...*/] })
is an internal event that no one must know, not even the machine' tests, because it's an implementation detail
To satisfy the above points, I prefer a test that looks like
// ARRANGE
const { service, resolveFetchMock } = createMockedMachine()
// ACT
// NOTE: it's an external event, it's fine expliciting it
service.send({ type: 'START' })
// NOTE: exxternal services are managed through dedicated utilities, allow concentrating on the `data`
// the service should return
resolveFetchMock(defaultFetchData)
// ASSERT
expect(service.state).toMatchObject({
value: 'success',
context: { people: defaultFetchData, fetching: false },
})
-
createMockedMachine utility
: I first written all the tests, then carefully analyzed which code was common for all the tests that worsened their readability. ThecreateMockedMachine
utility creates a minimal configured machine but returns some utilities that control the fakefetchPeople
service. -
clear testing blocks: every test, short or long, have clear
ARRANGE/ACT/ASSERT
blocks -
test approach: to test this machine, there are three main kinds of approaches
- a functional test-like approach: a user flow is performed, there isn't a clear "end" of the test, multiple-input combinations are tried in the same test
- a single-flow approach: every test contains multiple steps but is oriented to a single, clear, final result
- a single-transition approach: the machine starts in a specific state, an event is sent, the next state is checked out
I opted for the second approach because:
- the first approach leads too much to create a clone of the functional, browser-based, tests
- the first approach generates very long tests, hard to follow without a "visual" counterpart
- the third approach is too much oriented to test the machine' implementation details
- the third approach needs the developer to know the XState' internal events
Test examples
First approach example
it('When the fetch fails, should allow retrying with the same filter and clear the errors', async () => {
// ARRANGE
const debounceDelay = 1
const query = 'Ann Henry'
const filteredFetchData = [annHenry]
const {
service,
fetchMock,
rejectFetchMock,
resolveFetchMock,
waitDebouncedFetch,
} = createMockedMachine(debounceDelay)
// 1. START THE MACHINE
service.send({ type: 'START' })
expect(fetchMock).toHaveBeenCalledTimes(1)
// 2. REJECT THE FETCH
rejectFetchMock(error)
expect(service.state).toMatchObject({
value: 'failure',
context: { fetchErrors: [error] },
})
// 3. CHANGE THE QUERY
service.send({ type: 'SET_QUERY', query })
// 4. DEBOUNCED FETCH
await waitDebouncedFetch()
// 5. REJECT THE FETCH
rejectFetchMock(error)
expect(service.state).toMatchObject({ context: { fetchErrors: [error, error] } })
expect(fetchMock).toHaveBeenCalledTimes(2)
// 6. RETRY FETCHING
service.send({ type: 'RETRY' })
// 7. RESOLVE THE FETCH
resolveFetchMock(filteredFetchData)
expect(service.state).toMatchObject({
value: 'success',
context: { fetchErrors: [], people: filteredFetchData },
})
expect(fetchMock).toHaveBeenCalledTimes(3)
// 8. CHECK THE THIRD FETCH' QUERY
expect(fetchMock).toHaveBeenLastCalledWith(
// machine' context
expect.objectContaining({ filter: expect.objectContaining({ query }) }),
// machine' states and invokeMeta, useless for the purpose of the tests
expect.anything(),
expect.anything(),
)
})
Second approach example
it('When retrying, should retry with the last query', async () => {
// ARRANGE
const query = 'Ann Henry'
const {
service,
fetchMock,
rejectFetchMock,
resolveFetchMock,
waitDebouncedFetch,
} = createMockedMachine(1)
// ACT
service.send({ type: 'START' })
resolveFetchMock(defaultFetchData)
service.send({ type: 'SET_QUERY', query })
await waitDebouncedFetch()
rejectFetchMock(error)
service.send({ type: 'RETRY' })
await waitDebouncedFetch()
// ASSERT
expect(fetchMock).toHaveBeenLastCalledWith(
// machine' context
expect.objectContaining({ filter: expect.objectContaining({ query }) }),
// machine' states and invokeMeta, useless for the purpose of the tests
expect.anything(),
expect.anything(),
)
})
Third approach example
it('When the "FAILURE" event occurs, should move from the "fetch" state to the "failure" one', () => {
// ARRANGE
const { machineMock } = createMockedMachine()
// ACT
const actualState = machineMock.transition('fetch', {
type: 'FAILURE',
data: error,
})
// ASSERT
expect(actualState).toMatchObject({
value: 'failure',
context: { fetching: false, fetchErrors: [error] },
})
})
The render tree must be wrapped by the MachineRoot
component, that stores the running machine (the service) in a React Context.
The fact that the running machine is available through a React Context is an implementation detail that the consumers must be unaware of. All the components that need to connect to the running machine must use the exported useMachine
hook, which completely hides the usage of the React Context. This is particularly helpful for future refactors.
The proposed Finite State Machine is strictly coupled to the UI of the People list. It allows:
-
to fetch the People when the machine starts
-
to change the filter (composed by the
query
and theemployment
type) -
to debounce the next fetch when the filter change
-
to know if the last fetch went well or not
-
to access the fetched People
-
to access the queue of errors that happened in the last fetches, if they failed
-
to retry the previously failed request
-
to cancel the previous requests. The search/filter inputs aren't disabled when there is an ongoing fetch. Canceling the previous fetches avoids managing their race conditions
Features not included that could be considered in the future:
- forcing the fetch: this feature will become necessary only if
- the debounce will be increased to something noticeable by the users (ex. 800+ ms)
- the People list will present to the user a way to force the fetch or something that requires triggering an immediate fetch
The default filter includes both the employees and the contractors.