use-backlash
TypeScript icon, indicating that this package has built-in type declarations

0.0.32 • Public • Published

use-backlash

npm build publish codecov Type Coverage Libraries.io dependency status for latest release bundlejs npm

useReducer with effects

Getting started

npm i use-backlash

Description

This hook is a basic approach to split view/logic/effects in React. It works in StrictMode and is easy to test. It is designed to be framework-agnostic and was tested with react and preact.

Tutorial

This is going to be a Counter.

import React, { useRef, useState, useEffect, useLayoutEffect } from 'react'
import { UpdateMap, createBacklash } from 'use-backlash'

// A framework should provide react-like hooks
const useBacklash = createBacklash({ useRef, useState, useEffect, useLayoutEffect })

// State can be anything,
type State = number

// but an Action is always a record of tuples, where the key
// is the name of an action and value is a list of arguments.
type Action = {
  inc: []
  dec: []
}

// init is a pure (in react terms) function that has no arguments
// and just returns the initial state wrapped in array.
const init = () => [0] as const

// Unlike the standard useReducer, update/reducer is not a function with
// a switch statement inside, it is an object where each key is an action
// name and each value is a reducer that takes a state, rest action
// elements (if any) and returns next state wrapped in array. There is
// a helper UpdateMap type, that checks the shape of update object and
// makes writing types by hand optional.
const update: UpdateMap<State, Action> = {
  inc: (state) => [state + 1],

  dec: (state) => [state - 1]
}

export const Counter = () => {
  // In this example useBacklash hook takes init & update functions and
  // returns a tuple containing state & actions. Note that 'init' & 'update'
  // arguments of useBacklash is 'initial' and changing these things won't
  // affect the behavior of the hook. Also the actions object is guaranteed
  // to remain the same during rerenders just like useReducer's dispatch
  // function.
  const [state, actions] = useBacklash(init, update)

  return (
    <>
      <div>{state}</div>
      <button onClick={actions.inc}>inc</button>
      <button onClick={actions.dec}>dec</button>
    </>
  )
}

Passing arguments to init function.

// Let's change the init function to have a single parameter
const init = (count: number) => [count] as const

// ...

export const Counter = () => {
  // Inside useBacklash body init function is called only once,
  // so it is ok to inline it.
  const [state, actions] = useBacklash(() => init(5), update)

  // ...
}

For now useBacklash was used just as a fancy useReducer that returns an actions object instead of dispatch function. It doesn't make much sense to use it like this instead of useReducer. So let's make that counter persistent and see how useBacklash helps to handle side effects.

// We are going to use localStorage to store the state of the Counter.
// Since I/O is a side effect it can not be called directly from the init
// function. To model the situation 'state is not set yet' State type will
// be extended with 'loading' string literal.
type State = 'loading' | number

// Additional action 'loaded' will notify that Counter state is loaded.
type Action = {
  loaded: [count: number]
  inc: []
  dec: []
}

const key = 'counter_key'

// init and each update property functions return
// the value of Command type - [State, ...Effect[]]
export const init = (): Command<State, Action> => [
  'loading',
  // The next function is a side effect that will be called by useBacklash
  // internally. Here it has single parameter - the same actions object
  // that is returned from useBacklash call.
  ({ loaded }) => loaded(Number(localStorage.getItem(key)) || 0)
  // Additional can be added after the first one
  // and all of them will run in order.
]

export const update: UpdateMap<State, Action> = {
  // The second parameter is a value that was passed to the 'loaded' action
  // a few lines earlier.
  loaded: (_, count) => [count],

  inc: (state) => {
    // If someone manages to call 'inc' before the state is loaded,
    // just do nothing, that's the normal strategy for this example.
    if (state === 'loading') {
      return [state]
    }

    const next = state + 1

    // Like the init function an update returns a Command
    return [next, () => localStorage.setItem(key, `${next}`)]
  },

  dec: (state) => {
    if (state === 'loading') {
      return [state]
    }

    // This line is the only difference between 'inc' and 'dec'.
    // Probably I will refactor it someday...
    const next = state - 1

    return [next, () => localStorage.setItem(key, `${next}`)]
  }
}

export const Counter = () => {
  const [state, actions] = useBacklash(init, update)

  return state === 'loading' ? null : (
    <>
      <div>{state}</div>
      <button onClick={actions.inc}>inc</button>
      <button onClick={actions.dec}>dec</button>
    </>
  )
}

Sample test.

import { act, renderHook } from '@testing-library/react'
import { useBacklash } from '../src'
import { init, update } from '../src/Counter'

describe.only('Counter', () => {
  test('state should equal 1 after inc', () => {
    const { result } = renderHook(() => useBacklash(init, update))

    act(() => {
      result.current[1].inc()
    })

    expect(result.current[0]).toEqual(1)
  })
})

When running this test with jest in jsdom test environment everything works as expected. But let's imagine that we don't have access to localStorage in our test environment. In this case test will fail with error: ReferenceError: localStorage is not defined. To avoid these kind of errors, useBacklash has an optional third parameter - injects. This parameter's value will be passed as a second argument to every effect function.

  import React from 'react'
  import { Command, UpdateMap, useBacklash } from '../'

  type State = 'loading' | number

  type Action = {
    loaded: [count: number]
    inc: []
    dec: []
  }

+ type Injects = {
+   readonly getItem: Storage['getItem']
+   readonly setItem: Storage['setItem']
+ }

  const key = 'counter_key'

- export const init = (): Command<State, Action> => [
+ export const init = (): Command<State, Action, Injects> => [
    'loading',
-   ({ loaded }) => loaded(Number(localStorage.getItem(key)) || 0)
+   ({ loaded }, { getItem }) => loaded(Number(getItem(key)) || 0)
  ]

- export const update: UpdateMap<State, Action> = {
+ export const update: UpdateMap<State, Action, Injects> = {
    loaded: (_, count) => [count],

    inc: (state) => {
      if (state === 'loading') {
        return [state]
      }

      const next = state + 1

-     return [next, () => localStorage.setItem(key, `${next}`)]
+     return [next, (_, { setItem }) => setItem(key, `${next}`)]
    },

    dec: (state) => {
      if (state === 'loading') {
        return [state]
      }

      const next = state - 1

-     return [next, () => localStorage.setItem(key, `${next}`)]
+     return [next, (_, { setItem }) => setItem(key, `${next}`)]
    }
  }

  export const Counter = () => {
-   const [state, actions] = useBacklash(init, update)
+   // Updating 'injects' doesn't trigger rerenders, so it is safe to inline it.
+   const [state, actions] = useBacklash(init, update, {
+     getItem: ((...args) => localStorage.getItem(...args)) as Storage['getItem'],
+     setItem: ((...args) => localStorage.setItem(...args)) as Storage['setItem']
+   })

    return state === 'loading' ? null : (
      <>
        <div>{state}</div>
        <button onClick={actions.inc}>inc</button>
        <button onClick={actions.dec}>dec</button>
      </>
    )
}

Now the test can be rewritten with mocked localStorage:

test('state should equal 1 after inc', () => {
  let storage: string | null = null

  const { result } = renderHook(() =>
    useBacklash(init, update, {
      getItem: (_: string) => storage,
      setItem: (_: string, value: string) => {
        storage = `${value}`
      }
    })
  )

  act(() => {
    result.current[1].inc()
  })

  expect(result.current[0]).toEqual(1)
  expect(storage).toEqual('1')
})

Trivia

It was developed as a boilerplate-free substitute of ts-elmish project. While it doesn't support effect composition or complex effect creators, it should be easier to grasp and have enough power to handle important parts of the UI-logic for any component.

Package Sidebar

Install

npm i use-backlash

Weekly Downloads

141

Version

0.0.32

License

MIT

Unpacked Size

69 kB

Total Files

13

Last publish

Collaborators

  • iyegoroff