Writing UI components

Writing UI components in the react-states architecture removes a lot of complexity when writing UIs. All state management, data fetching and environment effects related to the domain of the application is not the concern of any components defining UI. They rather consume this as feature hooks. That does not mean UI components does not have state or side effects, but they are tied to the UI itself, not the domain of the application.

With explicit states you get a super power when consuming the feature to create a UI. We can ensure that any state the feature can be in is actually covered by the UI implementation. This is done by matching over the states of the features.

const Auth = () => {
  const [auth, dispatch] = useAuth()
  
  return match(auth, {
    UNAUTHENTICATED: () => <button onClick={
      () => dispatch{ type: 'SIGN_IN' })
    }>Sign In</button>,
    AUTHENTICATING: () => <button disabled={true}>Signing In...</button>,
    AUTHENTICATED: ({ user }) => <Dashboard user={user} />,
    ERROR: ({ error }) => <h4>Oh no, ${error}</h4>
  })
}

If any state is added, changed or removed in the feature you know that the usage of match will notify you about that change in the UI implementation. That means you should favour using match as it gives more predictability.

Matching to styles

The match can map to anything, for example the styling of en element.

const SomeComponent = () => {
  const [auth] = useAuth()
  
  const style = match(auth, {
    UNAUTHENTICATED: () => ({ opacity: 1 }),
    AUTHENTICATING: () => ({ opacity: 0.5 }),
    AUTHENTICATED: () => ({ opacity: 0 }),
    ERROR: () => ({ opacity: 1 })
  })
}

Matching to variants

You can also create your own custom mapping structure which helps defining the UI.

const SomeComponent = () => {
  const [auth] = useAuth()
  
  const variant = match(auth, {
    UNAUTHENTICATED: () => ({
      icon: <FaUnlock />,
      opacity: 1,
      text: 'Sign In'
    }),
    AUTHENTICATING: () => ({
      icon: <FaSpinner />,
      opacity: 0.5,
      text: null'
    }),
    AUTHENTICATED: () => ({}),
    ERROR: () => ({})
  })
}

What is feature and what is UI?

Deciding what logic should be contained within a feature and what is strictly UI related can sometimes be difficult, but you can not really do it wrong, it is only a matter of consequences. The rule of thumb when bringing logic like state and effects into a UI component is to identify if that state and logic is tied to the UI itself, not something related to the application. An example of that would be:

const SomeComponent = () => {
  const [, dispatch] = useSomeFeature()
  const [input, setInput] = useState('')
  
  return (
    <form onSubmit={() => {
      dispatch({
        type: 'SUBMIT_INPUT',
        input
      })
    }}>
      <input value={input} onChange={() => setInput(event.target.value)} />
    </form>
  )
}

The feature does not care about the input coming from an actual HTML input, this is a UI concern.

An other example would be:

const SomeComponent = () => {
  const [, dispatch] = useSomeFeature()
  const inputRef = useRef<HTMLInputElement>(null)
  
  useEffect(() => {
    inputRef.current.focus()
  }, [])
  
  return <input ref={inputRef} /> 
}

The feature does not care about the input getting focus when the component mounts.

But the state of a feature can intersect with UI interaction. For example:

const SomeComponent = () => {
  const [state, dispatch] = useSomeFeature()
  const animation = useAnimation()
  
  useStateEffect(state, 'AUTHENTICATING', () => 
    animation.start()
  )
  
  return <div style={animation.style} /> 
}

Explicit states for complex interactions

The createReducer utility is not restricted to features. You can use it in components to deal with complex UI changes and interactions. An example of that would be drag and drop:

import { createReducer, match, States, StatesTransition } from 'react-states'

type State =
  | {
    state: 'IDLE'
  }
  | {
    state: 'DETECTING_DRAG'
    initialX: number
    initialY: number
  }
  | {
    state: 'DRAGGING'
    x: number
    y: number
  };
  
type Action =
  | {
    type: 'MOUSE_MOVE'
    x: number
    y: number
  }
  | {
    type: 'MOUSE_UP'
    x: number
    y: number
  }
  | {
    type: 'MOUSE_DOWN'
    x: number
    y: number
  }
  
type DragDrop = States<State, Action>

type Transition = StatesTransition<DragDrop>

const reducer = createsReducer<DragDrop>({
  IDLE: {
    MOUSE_DOWN: (_, { x, y }): Transition => ({
      state: 'DETECT_DRAG',
      initialX: x,
      initialY: y
    })
  },
  DETECT_DRAG: {
    MOUSE_MOVE: (detectDragState, { x, y }): Transition => {
      if (
        Math.abs(x - detectDragContext.initialX) > 5 || 
        Math.abs(y - detectDragContext.initialY) > 5
      ) {
        return { state: 'DRAGGING', x, y }
      }
      
      return detectDragContext
    } 
  },
  DRAGGING: {
    MOUSE_MOVE: (_, { x, y }): Transition => ({ state: 'DRAGGING', x, y }),
    MOUSE_UP: (): Transition => ({ state: 'IDLE' }) 
  }
})

export const ComponentA = () => {
  const [dragDrop, dispatch] = useReducer(reducer, {
    state: 'IDLE'
  })
  
  const events = {
    onMouseMove: (event: MouseEvent) => 
      dispatch({
        type: 'MOUSE_MOVE',
        x: event.clientX,
        y: event.clientY
      }),
    onMouseUp: (event: MouseEvent) => 
      dispatch({
        type: 'MOUSE_UP',
        x: event.clientX,
        y: event.clientY
      }),
    onMouseDown: (event: MouseEvent) => 
      dispatch{
        type: 'MOUSE_DOWN',
        x: event.clientX,
        y: event.clientY
      })
  }
  
  const props = match(dragDrop, {
    IDLE: () => events,
    DETECT_DRAG: () => events,
    DRAGGING: ({ x, y }) => ({ ...events, style: { left: x, top: y } })
  })
  
  return <div {...props} />
}

Last updated

Was this helpful?