Dependency Injection for Toast Notifications with Xstate
• ⏱︎ 4 min read
🎯 What Problem Are We Trying to This Solve?
- We want to control the flow of the app with a state machine.
- We want to send a toast notification when a certain state is reached in our app.
- We want the machine to be flexible and not have to know about the UI.
There are three parts to this:
-
xstate Machine
- Define the states and transitions.
- This is the core of the logic:
- Fetching movies.
- On success, storing the data and calling an action to display a toast notification.
-
Custom React Hook
- We use dependency injection to pass actors and actions to the state machine.
- This means that the state machine calls certain functions at specific points in the state flow.
- This is where we pass in the API call to fetch movies and the toast notification.
-
React Component
- Point of composition for UI elements and react hook that handles the machine.
Create a State Machine to Control the Flow of the App
// Different types of events that can be sent to the machine
export type FetchMoviesDoneEvent = {
type: 'xstate.done.actor.fetchMovies'
output: {
data: Movie[]
toast: {
title: JSX.Element
description: string
}
}
}
export type FetchMoviesErrorEvent = {
type: 'xstate.error.actor.fetchMovies'
error: Error
}
export type MachineEvents =
| { type: 'FETCH_MOVIES' }
| FetchMoviesDoneEvent
| FetchMoviesErrorEvent
// Context is the data that the machine can access
export type MachineContext = {
movies: Movie[]
}
// We wrap the machine in a function so that we can pass in the default movies
// into `context` when we create the machine
export const createMoviesMachine = (defaultMovies: Movie[] = []) => {
const machine = setup({
types: {} as {
context: MachineContext
events: MachineEvents
},
actors: {
// The fetchMovies actor is a promise that returns the movies data and a toast message
fetchMovies: fromPromise(async () => {
return {} as FetchMoviesDoneEvent['output']
})
},
actions: {
// Placeholder actions to be implemented later
setMovies: () => {
throw new Error('Implement me')
},
toastSuccess: () => {
throw new Error('Implement me')
},
toastError: () => {
throw new Error('Implement me')
}
}
})
.createMachine({
initial: 'idle',
context: {
movies: defaultMovies
},
on: {
FETCH_MOVIES: {
target: '.fetching movies'
}
},
states: {
idle: {},
'fetching movies': {
invoke: {
id: 'fetchMovies',
src: 'fetchMovies',
onDone: {
target: 'success',
actions: ['setMovies', 'toastSuccess']
},
onError: {
target: 'error',
actions: ['toastError']
}
}
},
success: {},
error: {}
}
})
return machine
}
Custom React Hook to Use the Machine and Inject Dependencies
This is where we’ll pass in:
fetchMovies
: the API call to fetch movies.toastSuccess
: a call to render a success toast notification.toastError
: a call to render an error toast notification.
export const useMovieMachine = (defaultMovies: Movie[]) => {
const { toast } = useToast()
const machine = useMemo(() => {
return createMoviesMachine(defaultMovies)
}, [])
const [state, send] = useMachine(
// 'machine.provide' is a way to inject dependencies into the machine
machine.provide({
actors: {
// A wrapper around our actual `api` call
// that returns data and what the toast message should be.
// We can blur the lines here between UI and Logic and render a toast notification using a string or an element
fetchMovies: fromPromise(async () => {
const data = await movieClient.getMovies({ genre: '16' })
return {
data: data.results,
toast: {
title: (
<span>
Successfully loaded {data.results.length} movies
out of {data.total_results.toLocaleString()} total movies.
</span>
),
}
}
})
},
actions: {
// store the results from the api call
setMovies: assign({
movies: ({ context, event }) => {
assertEvent(event, 'xstate.done.actor.fetchMovies')
const currentIds = new Set(context.movies.map((m) => m.id))
return [
...event.output.data.filter((movie) => !currentIds.has(movie.id)),
...context.movies
]
}
}),
toastSuccess: ({ event }) => {
assertEvent(event, 'xstate.done.actor.fetchMovies')
toast({
variant: 'primary',
title: event.output.toast.title,
description: event.output.toast.description
})
},
toastError: ({ event }) => {
assertEvent(event, 'xstate.error.actor.fetchMovies')
toast({
title: 'Error',
description: event.error.message || 'An error occurred'
})
}
}
})
)
return { state, send, machine }
}
React Component Where We Compose the Machine and the Hook
export function Movies() {
const { state, send } = useMovieMachine([])
const handleClick = () => {
send({ type: 'FETCH_MOVIES' })
}
return (
<div className="grid gap-4 mx-auto max-w-3xl p-8">
<div className="flex items-center justify-between">
<div className="flex gap-2 items-end">
<span className="text-2xl">Current State:</span>
<span
className={cn('font-mono text-xl flex items-center gap-2', {
'text-gray-500': state.matches('idle'),
'text-orange-400': state.matches('fetching movies'),
'text-primary-foreground': state.matches('success')
})}
>
{state.value}
{state.matches('fetching movies') && <Loading />}
</span>
</div>
<Button disabled={state.matches('fetching movies')} onClick={handleClick}>
Fetch Movies
</Button>
</div>
{state.matches('success') && (
<div className="mx-auto max-w-3xl p-8 ">
<MovieList movies={state.context.movies} />
</div>
)}
{state.matches('fetching movies') && state.context.movies.length > 0 && (
<div className="mx-auto max-w-3xl p-8 ">
<MovieList movies={state.context.movies} />
</div>
)}
</div>
)
}
const Loading = () => (
<div className="flex justify-center items-center h-full text-white">
<Loader2 className="animate-spin" />
</div>
)