📄
react-states
  • react-states API
  • Introduction
  • Our journey
  • The Mental Model
  • Adopting react-states
  • Reference implementations
  • Environment interface
  • Subscriptions
  • Features
  • Writing UI components
  • Explicit states
  • State transitions
  • Transition effects
  • Feature testing
  • Files and folders
  • Patterns
  • PR review guide
  • Devtools
Powered by GitBook
On this page
  • Consuming a feature
  • Implementing a Feature
  • Creating data types
  • Exploding features

Was this helpful?

Features

PreviousSubscriptionsNextWriting UI components

Last updated 3 years ago

Was this helpful?

All application logic is defined within a Feature. Combing a with react-states allows us to drive both UI and side effects with only dispatching and consuming explicit state.

Consuming a feature

import { match } from 'react-states'
import { useAuth } from '../features/AuthFeature'

const Auth = () => {
  const [auth, dispatch] = useAuth()
  
  /*
    The feature allows you to match on the current
    states. This matching is exhaustive, meaning you have to define
    what you want to return in each state.
  */
  return match(auth, {
    UNAUTHENTICATED: () => (
      <button onClick={() => {
        /*
          The only way to drive the application forward is to
          dispatch. The feature decides if this intent
          to move the application forward is valid, which again
          triggers any necessary side effects for that new state
        */
        dispatch({ type: 'SIGN_IN' })
      }}>Sign In</button>
    ),
    AUTHENTICATING: () => (
      <div>Authenticating...</div>
    ),
    AUTHENTICATED: ({ user }) => (
      <h1>Hello ${user.name}</h1>
    ),
    ERROR: ({ error }) => (
      <h4>Ops, something bad happened: {error}</h4>
    )
  })
}

Best practices

Implementing a Feature

The main file is called Feature.tsx. The naming conventions within a feature is generic, meaning every type and variable points to "feature" and not the domain the feature represents. This keeps feature implementations as similar as possible, you only parse the implementation details and not the generic types and variables. You will rather use the index.ts file to expose named exports.

features/Auth/index.ts
export {
  FeatureProvider as AuthProvider,
  useFeature as useAuth
} from './Feature'

export type {
  User,
  PublicFeature as Auth
} from './Feature'

Best practices

features/Auth/Feature.tsx
import React, { createContext, useContext } from 'react'
import {
  createReducer,
  useStateEffect,
  useCommandEffect,
  useSubscription,
  States,
  StatesTransition
} from 'react-states'
import { ApiAction } from '../environment/api'

/*
  Types specifically related to the feature is exported from
  the feature
*/
export type User = { name: string }

export type State =
  | {
    state: 'SIGNED_OUT'
  }
  | {
    state: 'SIGNED_IN'
    user: User
  }
  | {
    state: 'SIGNING_OUT'
  }
  | {
    state: 'SIGNING_IN'
  }
  | {
    state: 'ERROR'
    error: string
  }
  
/*
  Dispatches that should only happen within the feature is
  defined with its own type
*/
export type PrivateAction = {
  type: 'ERROR_TIMEOUT'
}

/*
  Dispatches that can be used by consuming components
  is defined in its own type prefixed by Public
*/
export type PublicAction = {
  type: 'SIGN_IN'
}

/*
  Commands that can be run related to state transitions
*/
export type Command = {
  type: 'RELOAD_APP'
}

/*
  The tuple returned from useReducer, typed with
  only public actions
*/
export type PublicFeature = States<State, PublicAction>

/*
  The tuple returned from useReducer
*/
export type Feature = States<State, PrivateAction | PublicAction | ApiAction, Command>

/*
  Exact return type for transition handlers in the reducer
*/
export type Transition = StateTransition<Feature>


/* 
  Generate a context for the public version of the feature
*/ 
const featureContext = createContext({} as PublicFeature)

/*
  We also define a hook to consume the feature
*/
export const useFeature = () => useContext(reducerContext)

/*
  We create the reducer which expresses state transitions and guards
  these transitions by only allowing certain actions in certain states.
*/
const reducer = createReducer<Feature>({
  SIGNING_IN: {
    /*
      This feature is subscribing to the Api and has access to deal
      with any of its actions. The first argument is the current state,
      "SIGNING_IN" in this case. The second argument is the action
    */
    'API:AUTHENTICATED': (state, { user }): Transition => ({
      state: 'SIGNED_IN',
      user
    }),
    /*
      We can return a new state, optionally using data from
      the action and/or existing state, or just keep the existing
      state
    */
    'API:UNAUTHENTICATED': (): Transition => ({ state: 'SIGNED_OUT' }),
    /*
      Currently you have to set an explicit return type to ensure
      full type safety of the transition, but TypeScript will
      have a new Exact utility that removes this requirement
    */
    'API:SIGN_IN_ERROR': ({ error }): Transition => ({ state: 'ERROR', error })
  },
  SIGNED_IN: {},
  SIGNING_OUT: {
    /*
      When we want to run effects with relying on changing the explicit state,
      we can return a tuple where the second item is a command
    */
    'API:UNAUTHENTICATED': (state): Transition => [
      state,
      {
        cmd: 'RELOAD_APP'
      }
    ],
    'API:SIGN_OUT_ERROR': ({ error }): Transition => ({ state: 'ERROR', error })
  },
  SIGNED_OUT: {
    SIGN_IN: (): Transition => ({ state: 'SIGNING_IN' })
  },
  ERROR: {}
})

interface Props {
  children: React.ReactNode
  initialState?: State
}

const FeatureProvider = ({
  children,
  /*
    We set a default initial state, which can be changed
    during testing
  */
  initialState = { state: 'SIGNING_IN' }
}: Props) => {
  /*
    The react-states architecture separates your application from
    the environment it is running in
  */
  const { api, env } = useEnvironment()
  
  /* 
    We initialize our reducer with the initial state
  */ 
  const feature = useReducer(reducer, initialState)
  
  /*
    We destructure for conveniance inside the feature
  */
  const [state, dispatch] = feature
  
  /*
    We funnel any events from the API into our reducer
  */
  useSubscription(api.subscription, dispatch)
  
  /*
    The react-states architecture only allows components to dispatch
    actions to the feature which transitions the state. That means
    side effects can only trigger as a reaction to a state transitions
  */
  useStateEffect(state, 'SIGNING_IN', () => api.signIn())
  
  /*
    We can also trigger side effects related to commands
  */
  useCommandEffect(state, 'RELOAD_APP', () => env.reload())
  
  /*
    We expose the state and dispatch on the context of the provider. It is important
    that we reference the value returned from "useReducer" as this ensures
    optimal reconciliation
  */
  return (
    <featureContext.Provider value={feature}>
      {children}
    </featureContext.Provider>
}

Best practices

Creating data types

Sometimes features consumes data from the environment. Typically the types returned from an environment interface is exactly what you want to expose in the state of your feature, but not always. No matter all the types that a feature consumes should also be exported from the feature. That means:

import { SandboxDTO } from './environment/api'

// Sometimes we just reuse a type from the environment
export type Sandbox = SandboxDTO

// Other times we explicitly define the type, where 
// some environment effect might give us way more data,
// but we do not care about it
export type User = { username: string }

const SandboxFeature = () => {}

This pattern ensures that whenever a type is inferred from a feature, you will always find it on the feature itself. This means you can discover all the types a feature defines by looking at its exports.

Best practices

Exploding features

Certain features might have many transition effects and/or states. In that case it can be an idea to split the feature into parts.

features/
  AFeature/
    index.ts
    Feature.test.tsx
    Feature.tsx
    reducer.ts
    effects.ts
    types.ts

The index file exports a named version of the provider and types.

features/FeatureA/index.ts
export {
  FeatureProvider as AProvider,
  useFeature as useA
} from './Feature'

export type {
  PublicFeature as A
} from './Feature'

The Feature file should only contain the definition of the provider component itself.

features/featureA/Feature.tsx
import React, { createContext } from 'react'
import { reducer } from  './reducer'
import { State, PublicFeature } from './types'
import { useApi, useSignInEffect } from './effects'

export * from './types'

const featureContext = createContext({} as PublicFeature)

interface Props {
  children: React.ReactNode
  initialState?: State
}

export const Feature = ({
  children,
  initialState = { state: 'SIGNED_OUT' }
}: Props) => {
  const feature = useReducer(reducer, initialState)
  
  useApi(feature)
  useSignInEffect(feature)
  
  return (
    <featureContext.Provider value={feature}>
      {children}
    </featureContext.Provider>
  )
}

All types are defined in its own file.

features/featureA/types.ts
import { States } from 'react-states'
import { ApiAction } from '../environment/api'

export type User = { name: string }

export type State =
  | {
    state: 'SIGNED_IN'
    user: User
  }
  | {
    state: 'SIGNED_OUT'
  }
  | {
    state: 'SIGNING_IN'
  }
  | {
    state: 'SIGNING_OUT'
  }
  | {
    state: 'ERROR'
    error: string
  }

export type PrivateAction = {
  type: 'ERROR_TIMEOUT'
}

export type PublicAction = {
  type: 'SIGN_IN'
}

export type Command = {
  cmd: 'RELOAD_APP'
}

export type Transition = StateTransition<State, Command>

export type PublicFeature = States<State, PublicAction>

export type PrivateFeature = States<State, PrivateAction | PublicAction | ApiAction, Command>

The reducer file holds the definition of transitions in the reducer.

features/featureA/reducer.ts
import { createReducer } from 'react-states'
import { Transition, Feature } from './types'

export const reducer = createReducer<Feature>({
  SIGNING_IN: {
    'API:AUTHENTICATED': (_, { user }): Transition => ({ state: 'SIGNED_IN', user }),
    'API:UNAUTHENTICATED': (): Transition => ({ state: 'SIGNED_OUT' }),
    'API:SIGN_IN_ERROR': (_, { error }): Transition => ({ state: 'ERROR', error })
  },
  SIGNED_IN: {},
  SIGNED_OUT: {
    SIGN_IN: (): Transition => ({ state: 'SIGNING_IN' })
  },
  SIGNING_OUT: {
    'API:UNAUTHENTICATED': (state): Transition => [state, { cmd: 'RELOAD_APP' }],
    'API:SIGN_OUT_ERROR': ({ error }): Transition => ({ state: 'ERROR', error })
  },
  ERROR: {}
})

The effects file can have multiple effect definitions. Splitting the states between the effects, even having multiple effects on the same state. Organize them in related groups.

features/featureA/effects.ts
import { useStateEffect, useSubscription } from 'react-states'
import { useEnvironment } from '../environment'
import { Feature } from './types' 
  
export const useApi = ([, dispatch]: Feature) => {
  const { api } = useEnvironment()
  
  useSubscription(api.subscription, dispatch)
}

export const useSignInEffect = ([state]: Feature) => {
  const { api } = useEnvironment()
  
  useStateEffect(state, 'SIGNING_IN', () => api.signIn())
}
React context