Skip to content

Functional composition utilities for React hooks, styled after Recompose

License

Notifications You must be signed in to change notification settings

helixbass/ad-hok

Repository files navigation

ad-hok

ad-hok is a set of helpers that let you use React hooks in a functional pipeline style. Its API and concept are inspired by Recompose.

For an introductory comparison of ad-hok vs "vanilla" hooks, see this article.

Table of Contents

Installation

$ npm install --save ad-hok
Recommended: ESLint

If you're using ESLint, you may want to install eslint-plugin-ad-hok to enforce ad-hok best practices

$ npm install --save-dev eslint-plugin-ad-hok

In your .eslintrc:

"extends": ["plugin:ad-hok/recommended"]
Recommended: Babel display name plugin

If you're using Babel, you may want to install babel-plugin-transform-react-display-name-pipe for nicer component display names when debugging

$ npm install --save-dev babel-plugin-transform-react-display-name-pipe

In your .babelrc (or babel.config.js):

"plugins": ["transform-react-display-name-pipe"]

Alternatively, you can use addDisplayName() in your components to specify component display names

Basic usage

import {addState, addHandlers, flowMax} from 'ad-hok'

const Counter = flowMax(
  addState('count', 'setCount', 0),
  addHandlers({
    increment: ({ setCount }) => () => setCount(n => n + 1),
    decrement: ({ setCount }) => () =>  setCount(n => n - 1),
    reset: ({ setCount }) => () => setCount(0)
  }),
  ({count, increment, decrement, reset}) =>
    <>
      Count: {count}
      <button onClick={reset}>Reset</button>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </>
)

ad-hok helpers are composed using flowMax()

Compare this to using Recompose's compose():

import {compose, withState, withHandlers} from 'recompose'

const addCounting = compose(
  withState('count', 'setCount', 0),
  withHandlers({
    increment: ({ setCount }) => () => setCount(n => n + 1),
    decrement: ({ setCount }) => () =>  setCount(n => n - 1),
    reset: ({ setCount }) => () => setCount(0)
  })
)

const EnhancedComponent = addCounting(SomeComponent)

Usage with Typescript

Ad-hok works nicely with Typescript, see our guide to ad-hok + Typescript

API

addState()

addState(
  stateName: string,
  stateUpdaterName: string,
  initialState: any | (props: Object) => any
): Function

Adds two additional props: a state value, and a function to update that state value

Wraps useState() hook

Comparable to Recompose's withState()

For example:

const Counter = flowMax(
  addState('count', 'setCount', 0),
  ({count, setCount}) =>
    <>
      Count: {count}
      <button onClick={() => setCount(0)}>Reset</button>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
    </>
)

addEffect()

addEffect(
  callback: (props: Object) => Function,
  dependencies?: Array<string> | (oldProps: Object, newProps: Object) => boolean
): Function

Accepts a function of props that returns a function (which gets passed to useEffect()). Used for imperative, possibly effectful code

The optional second argument is a dependencies argument. It corresponds to the second argument to useEffect()

For example:

const Example = flowMax(
  addState('count', 'setCount', 0),
  addEffect(({count}) => () => {
    document.title = `You clicked ${count} times`
  }, ['count']),
  addEffect(() => () => {
    console.log("I get called on every re-render")
  }),
  addEffect(() => () => {
    console.log("I only get called once on mount")
  }, []),
  ({count, setCount}) =>
    <>
      Count: {count}
      <button onClick={() => setCount(0)}>Reset</button>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
    </>
)

addLayoutEffect()

addLayoutEffect(
  callback: (props: Object) => Function,
  dependencies?: Array<string> | (oldProps: Object, newProps: Object) => boolean
): Function

Accepts a function of props that returns a function (which gets passed to useLayoutEffect()). Used for imperative, possibly effectful code. The signature is identical to addEffect, but it fires synchronously after all DOM mutations

The optional second argument is a dependencies argument. It corresponds to the second argument to useLayoutEffect()

For example:

const Example = flowMax(
  addState('count', 'setCount', 0),
  addLayoutEffect(({count}) => () => {
    document.title = `You clicked ${count} times`
  }, ['count']),
  addLayoutEffect(() => () => {
    console.log("I get called on every re-render")
  }),
  addLayoutEffect(() => () => {
    console.log("I only get called once on mount")
  }, []),
  ({count, setCount}) =>
    <>
      Count: {count}
      <button onClick={() => setCount(0)}>Reset</button>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
    </>
)

addProps()

addProps(
  createProps: (incomingProps: Object) => Object | Object,
  dependencies?: Array<string> | (oldProps: Object, newProps: Object) => boolean
): Function

Accepts a function that returns additional props based on the incoming props. Or accepts an object of additional props. The additional props get merged with the incoming props

The optional second argument is a dependencies argument that controls memoization of the added props. This can be used to avoid expensive recomputation or to stabilize prop identity across rerenders (allowing for downstream shouldComponentUpdate()/React.memo() optimizations that rely on prop equality)

Doesn't wrap any hooks (other than useMemo() for the dependency tracking), just a convenience helper comparable to Recompose's withProps()

For example:

const Doubler = flowMax(
  addProps(({num}) => ({num: num * 2})),
  ({num}) =>
    <div>Number: {num}</div>
)

const Outer = () =>
  <Doubler num={3}> // renders "Number: 6"
  
const OptimizedDoubler = flowMax(
  addProps(({num}) => ({num: num * 2}), ['num']),
  ({num}) =>
    <div>Number: {num}</div>
)

addDefaultProps()

addDefaultProps(
  defaults: (incomingProps: Object) => Object | Object,
): Function

Adds some props only if they are not already present in the incoming props (ie. if they are null or undefined).

For example:

const Greeting = flowMax(
  addDefaultProps({
    name: 'world',
  }),
  ({name}) => <div>Hello, {name}!</div>
)

const AnonymousGreeting = () =>
  <Greeting /> // renders "Hello, world!"

const ReactGreeting = () =>
  <Greeting name="React" /> // renders "Hello, React!"

addHandlers()

addHandlers(
  handlerCreators: {
    [handlerName: string]: (props: Object) => Function
  },
  dependencies?: Array<string> | (oldProps: Object, newProps: Object) => boolean
): Function

Takes an object map of handler creators. These are higher-order functions that accept a set of props and return a function handler.

The optional second argument is a dependencies argument that controls memoizing the handlers. Stabilizing the handlers' identities across rerenders allows for downstream shouldComponentUpdate()/React.memo() optimizations that rely on prop equality

Doesn't wrap any hooks (other than useMemo() when passing the optional dependencies argument), just a convenience helper comparable to Recompose's withHandlers()

For example:

const ClickLogger = flowMax(
  addHandlers({
    onClick: ({onClick}) => (...args) => {
      console.log('click')
      onClick(...args)
    }
  }),
  ({onClick}) =>
    <button onClick={onClick}>click me</button>
)

const Fetcher = flowMax(
  addHandlers({
    onSubmit: ({someProp}) => () => {
      fetchData({someProp})
    },
  }, ['someProp']),
  ({onSubmit}) =>
    <ExpensiveToRender onSubmit={onSubmit} />
)

addStateHandlers()

addStateHandlers(
  initialState: Object | (props: Object) => Object
  stateUpdaters: {
    [key: string]: (state: Object, props: Object) => (...payload: any[]) => Object
  }
): Function

Adds additional props for state object properties and immutable updater functions in a form of (...payload: any[]) => Object

addStateHandlers() doesn't accept a dependencies argument because the identity of the exposed handlers is always stable

Wraps useReducer() hook

Comparable to Recompose's withStateHandlers()

For example:

const Counter = flowMax(
  addStateHandlers(
    {count: 0},
    {
      increment: ({count}) => () => ({count: count + 1}),
      decrement: ({count}) => () => ({count: count - 1}),
      reset: () => () => ({count: 0}),
    }
  ),
  ({count, increment, decrement, reset}) =>
    <>
      Count: {count}
      <button onClick={reset}>Reset</button>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </>
)

addRef()

addRef(
  refName: string,
  initialValue: (incomingProps: Object) => any | any,
): Function

Adds an additional prop of the given name whose value is a ref initialized to the given initial value

Wraps useRef() hook

For example:

const Example = flowMax(
  addRef('inputRef', null),
  ({inputRef}) =>
    <>
      <input ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>focus input</button>
    </>
)

addContext()

addContext(
  context: ReactContextObject,
  contextName: string,
): Function

Adds an additional prop of the given name whose value is the current value of the given React context object

Wraps useContext() hook

For example:

const ColorContext = React.createContext()

const Example = flowMax(
  addContext(ColorContext, 'color'),
  ({color}) =>
    <span>
      The current color is {color}
    </span>
)

const Outer = () =>
  <ColorContext.Provider value="red">
    <Example />
  </ColorContext.Provider>

addReducer()

addReducer(
  reducer: (prevState: Object, action: any) => Object,
  initialState: Object | (props: Object) => Object,
): Function

Adds additional props for state object properties and the reducer dispatch function

Wraps useReducer() hook

For example:

const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

const initialState = {count: 0}

const Counter = flow(
  addReducer(reducer, initialState),
  ({count, dispatch}) =>
    <>
      Count: {count}
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
    </>
)

addReducer() is a wrapper around a "static reducer". If you want to wrap a "reducer over props" (as described in this article), you can use addReducerOverProps() from ad-hok-utils. It should be mentioned that addStateHandlers() is similar to a reducer over props (in fact it's implemented using one) and is arguably more convenient/streamlined than a reducer is

addMemoBoundary()

addMemoBoundary(
  dependencies?: Array<string> | (oldProps: Object, newProps: Object) => boolean
): Function

Avoids unnecessary re-rendering of everything below it in a flowMax chain

Wraps React.memo

The optional argument is an array of names of props that should trigger re-rendering whenever one of those props changes (based on === comparison). If the argument is omitted, re-rendering will occur whenever any prop changes (like React.memo()'s default behavior)

For example:

const DefaultDoubler = flowMax(
  addMemoBoundary(), // this component will re-render whenever any of its props changes
  addProps(({num}) => ({doubled: num * 2})),
  ({doubled}) =>
    <div>Doubled: {doubled}</div>
)

const PropsArrayDoubler = flowMax(
  addMemoBoundary(["num"]), // this component will re-render whenever its `num` prop changes
  addProps(({num}) => ({doubled: num * 2})),
  ({doubled}) =>
    <div>Doubled: {doubled}</div>
)

branch()

branch(
  test: (props: Object) => boolean,
  left: (incomingProps: Object) => Object,
  right: ?(incomingProps: Object) => Object
): Function

A helper that accepts a test function and two functions. The test function is passed the incoming props. If it returns true, the left function is called with the incoming props; otherwise, the right function is called. If the right is not supplied, it will by default pass through the incoming props.

Doesn't wrap any hooks, just a convenience helper comparable to Recompose's branch()

Typically used along with renderNothing()/returns() to do conditional rendering

For example:

const Message = flowMax(
  branch(({hideMe}) => hideMe, renderNothing()),
  () =>
    <span>I'm not hidden!</span>
)

<Message hideMe /> // doesn't render anything
<Message /> // renders "I'm not hidden"

In order to avoid violating hooks invariants, both branches are rendered as separate components from the preceding steps of the flowMax()

If you don't want to have the branches rendered as components (eg when using flowMax() in a non-React context), you can use branchPure() instead

branchPure()

branchPure(
  test: (props: Object) => boolean,
  left: (incomingProps: Object) => Object,
  right: ?(incomingProps: Object) => Object
): Function

A helper that (like branch()) accepts a test function and two functions. The test function is passed the incoming props. If it returns true, the left function is called with the incoming props; otherwise, the right function is called. If the right is not supplied, it will by default pass through the incoming props.

The difference from branch() is that the branches are not automatically rendered as React components. Typically, it's a good idea to use branch() when using ad-hok in a React setting (ie the typical usage where a flowMax() is a React component) in order to avoid violating hooks invariants. So your rule of thumb should be to only use branchPure() inside non-React-component flowMax()'s

For example:

const returnsThreeSometimes = flowMax(
  branchPure(x => x > 1, returns(3)),
  x => x + 4
)

returnsThreeSometimes(2) // 3
returnsThreeSometimes(1) // 5

renderNothing()

renderNothing(): MagicReturnValue

A helper that always renders null (as the return value of the whole flowMax())

Doesn't wrap any hooks, just a convenience helper comparable to Recompose's renderNothing()

Typically used inside of branch()

For example:

const Message = flowMax(
  branch(({data}) => !data, renderNothing()),
  ({data}) =>
    <span>{data.message}</span>
)

returns()

returns(
  (props: Object) => returnValue: any
): MagicReturnValue

A helper that always returns the return value of its argument (as the return value of the whole flowMax())

Doesn't wrap any hooks, just a convenience helper somewhat comparable to Recompose's renderComponent()

Typically used inside of branch()

For example:

const Message = flowMax(
  branch(({data}) => !data, returns(() => <Loading />)),
  ({data}) =>
    <span>{data.message}</span>
)

addPropTypes()

addPropTypes(
  propTypes: object
): Function

A helper that assigns to the propTypes property of the current point in the flowMax()

Doesn't wrap any hooks, just a convenience helper comparable to Recompose's setPropTypes()

For example:

const Maybe = flowMax(
  addProps(({isIt}) => ({message: isIt ? 'yup' : 'no'}),
  addPropTypes({
    isIt: PropTypes.bool,
    message: PropTypes.string.isRequired
  }),
  ({message}) =>
    <span>{message}</span>
)

<Maybe isIt={true} />

addWrapper()

addWrapper(
  (
    render: (additionalProps: ?Object) => any,
    props: Object
  ) => any
): Function

A helper that allows taking control of the rendering of the rest of the flowMax() chain. Use cases could be to render additional JSX/markup wrapping or side-by-side with the JSX returned at the end of the flowMax()

The supplied callback receives a render function and the incoming props. render() will render the rest of the chain. It optionally accepts additional props to pass to the next step in the chain (which will also get "forwarded" the incoming props. Additional props passed via render() take precedence over forwarded props)

Doesn't wrap any hooks, just a convenience helper

For example:

const MaybeShowMessage = flowMax(
  addWrapper((render, props) =>
    <div>
      <h1>{props.header}</h1>
      {render()}
    </div>
  ),
  addProps(() => ({
    shouldShowMessage: !!random(),
  })),
  branch(({shouldShowMessage}) => !shouldShowMessage, renderNothing()),
  ({message}) =>
    <span>{message}</span>
)

<MaybeShowMessage
  header="This always gets rendered"
  message="This maybe gets rendered"
/>

addWrapperHOC()

addWrapperHOC(
  hoc: HigherOrderComponent
): Function

A helper that allows wrapping an existing higher-order component.

Doesn't wrap any hooks, just a convenience helper

For example:

import {withNavigation} from 'react-navigation'

const BackButton = flowMax(
  addWrapperHOC(withNavigation),
  ({navigation, children}) =>
    <Button onPress={() => navigation.goBack()}>
      <Text>{children}</Text>
    </Button>
)

<BackButton>Back</BackButton>

addDisplayName()

addDisplayName(
  displayName: string
): Function

Allows specifying a displayName for the component

Doesn't wrap any hooks, just a convenience helper

For example:

const MyComponent = flowMax(
  addDisplayName('MyComponent'),
  ({message}) => <div>{message}</div>
)

flowMax()

flowMax(
  pipelineFunction: (incomingProps: Object) => Object,
  ...
): Function

Used to compose helpers together into a single chain encapsulating your component

Comparable to Recompose's compose(). Works like lodash/fp's flow(), with some additional "magic"

For example:

const Message = flowMax(
  branch(({data}) => !data, returns(<Loading />)),
  ({data}) =>
    <span>{data.message}</span>
)

Dependencies arguments

Several helpers accept an optional "dependencies" argument specifying conditions under which that helper should "re-run" (eg recompute its values, or retrigger its effect). This is conceptually parallel to e.g. useEffect()'s or useMemo()'s dependencies argument

This dependencies argument can either be specified as a simple declarative "dependencies list" of props, or as a callback comparing old vs new props

A "dependencies list" is an array of strings that are Lodash get()-style "paths" into the props object. The helper re-runs whenever any of the corresponding values in the props object change identity

So for example this specifies that the effect should retrigger whenever the message prop changes or the language property of the settings prop changes:

addEffect(({message, settings}) => () => {
  const translatedMessage = getTranslatedMessage(message, settings.language)
  alert(translatedMessage)
}, ['message', 'settings.language'])

When the condition for re-running the helper can't easily be expressed as a dependencies list, you can instead use a callback that compares old vs new props and returns true if the helper should re-run:

addEffect(
  ({count}) => () => {
    console.log(`count just increased by > 1: ${count}`)
  },
  (oldProps, newProps) => newProps.cound - oldProps.count > 1
)

The callback won't be called on the initial render so you can always safely assume that oldProps exists

ad-hok-utils

For additional useful ad-hok helpers, check out ad-hok-utils

Bonus: use ad-hok/flowMax() in non-React contexts

Once you get used to ad-hok's helpers, you may find that some of them come in handy when writing typical non-React code where you'd usually use lodash/fp's flow()

For example, addProps() is a nice shorthand for obj => ({...obj, someAdditionalProps}):

flow(
  ({a, ...obj}) => ({
    ...obj,
    a,
    b: a + 2
  }),
  ...
)
// vs:
flowMax(
  addProps(({a}) => ({b: a + 2}),
  ...
)

And the control-flow helpers like branchPure()/returns() can be powerful:

const maybeReturnsThree = x => {
  if (x < 2) return 3
  
  return x + 4
}
// vs:
const maybeReturnsThree = flowMax(
  branchPure(x => x < 2, returns(() => 3)),
  x => x + 4
)

Motivation

Background

Recompose's approach is an elegant, highly composable way of building up logic out of small "building blocks" (in its case, higher-order components). Once I saw how clearly it expressed the separation between different pieces of functionality and the dependencies between them, it made writing class components look like an unappealing structure where you just have a "flat" set of methods and have to work hard to trace the dependencies between them

Enter hooks

Now React hooks have entered the picture. And they're very interesting and the promise of only using function components is great!

But I find the typical usage of hooks to still be less readable (and composable) than Recompose's style. By starting your function component bodies with a bunch of hooks, you're introducing a lot of local variable state. Visually tracking the dependencies between local variables is hard, many consider it good practice to minimize the use of local variables

So you can have the best of both worlds by using ad-hok:

  • Use hooks without introducing local variable state
  • Easily track dependencies between hooks when scanning code

Display components

Another thing you lose with typical hooks usage is the simple "display component" - your function component body becomes two sections, first a bunch of stuff related to hooks and then rendering. By using ad-hok, you regain the separation of actual rendering from surrounding logic

Reuse

In the simplest usage (as in the initial example), the final step in your flowMax() is a rendering function. If you want that rendering function to be an actual "display" function component (for reuse and/or eg prop types validation), just use React.createFactory():

import {createFactory} from 'react'

const DisplayCounter = ({count, increment, decrement, reset}) =>
  <>
    Count: {count}
    <button onClick={reset}>Reset</button>
    <button onClick={increment}>+</button>
    <button onClick={decrement}>-</button>
  </>

const Counter = flowMax(
  addState('count', 'setCount', 0),
  addHandlers({
    increment: ({ setCount }) => () => setCount(n => n + 1),
    decrement: ({ setCount }) => () =>  setCount(n => n - 1),
    reset: ({ setCount }) => () => setCount(0)
  }),
  createFactory(DisplayCounter) // or equivalently:   props => <DisplayCounter {...props} />
)

If you want to be able to reuse chunks of ad-hok "container" logic, just extract and name that part of the flowMax():

const addCounter = flowMax(
  addState('count', 'setCount', 0),
  addHandlers({
    increment: ({ setCount }) => () => setCount(n => n + 1),
    decrement: ({ setCount }) => () =>  setCount(n => n - 1),
    reset: ({ setCount }) => () => setCount(0)
  })
)

const Counter = flowMax(
  addCounter,
  ({count, increment, decrement, reset}) =>
    <>
      Count: {count}
      <button onClick={reset}>Reset</button>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </>
)

React hooks equivalents

React hook ad-hok
useState addState
useEffect addEffect
useContext addContext
useReducer addReducer
useCallback addHandlers with a dependencies argument
useMemo addProps with a dependencies argument
useRef addRef
useImperativeHandle -
useLayoutEffect addLayoutEffect
useDebugValue -

Help / Contributions / Feedback

Please file an issue or submit a PR with any questions/suggestions

License

MIT

About

Functional composition utilities for React hooks, styled after Recompose

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •