Dependency Injection for Toast Notifications with Xstate

⏱︎ 4 min read

🎯 What Problem Are We Trying to This Solve?

DI with xstate diagram

There are three parts to this:

  1. 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.
  2. 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.
  3. 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:

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