All application logic is defined within a Feature. Combing a React contextwith 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.
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.