Typed binding for recompose
/recompact
functions with
fluent interface builder
pattern.
React HoCs are quite common in todays projects, but Typescript at it's current
version (2.8) struggles with inferring generics with variadic arguments (the
compose()
function). You can learn more about this problem in
issue 5453).
This library was build as a workaround to cover the gap before TS catches up. It
doesn't provide any additional functionality to recompose
but offers
alternative way of specifying component composition which allows TS to correctly
infer types and avoid messing your code with <any>
or unnecessary interfaces.
Lets start with an example:
import * as React from 'react'
import { createComposer } from 'recompost'
export interface MyComponentProps {
name: string
}
const enhance = createComposer<MyComponentProps>()
.withPropsOnChange(['name'], ({ name }) => ({ salutation: `Hey ${name}!` }))
.withHandler('handleClick', ({ salutation }) => (e: React.MouseEvent<any>) => {
e.preventDefault()
console.log(salutation)
})
.build()
export const MyComponent = enhance(({
salutation,
handleClick
}) => (
<a href="#" onClick={handleClick}>{salutation}</a>
))
As you can see the usage is not much different from the compose()
function.
The usage starts with function createComposer<TInitialProps>()
which creates
object which is called ComponentDecoratorBuilder
. This object offers API for
chaining common decorators (described later on) and finally the build()
method. Once called, the build()
method creates composed component decorator
which can be used to wrap a stateless component.
The MyComponentProps
are initial props of the component (sometimes also called
outer props). They are basically the props that you can be passed by the USER of
the component.
The call of generated enhance()
method accept an actual stateless component
which will receive props which are result of all applied component decorators.
They are applied one by one and each can add, remove or override the props. We
call it the resulting props but you may also heard about them as inner props or
decorated props.
// .withProps(props: TAdditionalProps)
// .withProps(createProps: (props: TPreviousProps) => TAdditionalProps)
// .withPropsOnChange(propsToWatch: Array<keyof TPreviousProps>, (props: TPreviousProps) => TAdditionalProps)
// .withPropsOnChange(propsToWatch: (next: TPreviousProps, prev: TPreviousProps) => boolean, (props: TPreviousProps) => TAdditionalProps)
// See the introduction example.
Decorator withProps()
allows to pass additional props either by their
call-time value or by creating derived values from component props (own or
created as result of previous decorators).
Decorator withPropsOnChange()
offers performance optimization for derived prop
factories. You can use it for example to sort items or perform other operations
which you don't want to run on each render.
// .omitProps(propsToOmit: Array<keyof TPreviousProps>)
interface MyComponentProps extends React.HTMLProps<HTMLDivElement> {
kind: string
}
const enhance = createComposer<MyComponentProps>()
.withProps(({ kind, className }) => ({ className: [kind && `MyComponent--${kind}`, className].filter(c => c).join(' ') }))
.omitProps('type')
.build()
const MyComponent = enhance((props) => <div {...props} />)
Decorator omitProps()
allows to drop previously defined props before calling
the render function. This may be useful in case you are passing props directly
to some DOM component using spread operator. This is convenience only method and
it is not supported by Recompose originally. It is generally more favorable to
use a destructuring pattern instead, if you can.
// .withDefaultProps(defaultProps: Partial<TInitialProps>)
const enhance = createComposer<{ type?: string }>()
.withDefaultProps({ type: 'DEFAULT' })
.build()
Decorator withDefaultProps()
defines defaultProps
static property on
resulting component. Please be advised that because of this it is possible to
only define default props for the actual input props not props created by
chained decorators.
As a side-effect this function removes undefined
type from the resulting
defaulted props. This means that the component from the example will receive
props in shape { type: string }
instead of { type?: string | undefined }
defined by the initial props. This may help a lot while running TS in strict
mode.
// .withHandlers({ [k: string]: (props: TPreviousProps) => (...args: any[]) => any)
// .withHandlers(createHandlers: (props: TPreviousProps) => ({ [k: string]: (props: TPreviousProps) => (...args: any[]) => any)
// .withHandler<THandler extends () => any)(name: string, (props: TPreviousProps) => (...args: any[]) => any)
// See the introduction example.
Decorator withHandlers()
allows to define event handlers (or common function)
as higher-order functions. This allows the handler to access the current props
via closure variable, without needing to change its identity. Handlers are
passed to the base component as immutable props, whose identities are preserved
across renders. This avoids a common pitfall where functional components create
handlers inside the body of the render function, which results in a new function
being created on each render which breaks downstream shouldComponentUpdate()
optimizations that rely on the prop equality.
The optional variant with handler factory allows to create handlers with local scope variables. This can be used as a replacement for instance variables in class components. Here is an example:
const enhance = createComposer<{}>()
.withHandlers(() => {
let _lastKnownValue: number
return {
handleClick: () => (n: number) => {
console.log(`Current value: ${n}, last known value ${_lastKnownValue}.`)
_lastKnownValue = n
}
}
})
.build()
// .withState(propName: string, setterPropName: string, initialValue: TState, derivedStateFromProps?: (props: TPreviousProps, state: TState) => Partial<TState> | null)
// .withState(propName: string, setterPropName: string, initialValueMapper: (props: TPreviousProps) => TState, derivedStateFromProps?: (props: TPreviousProps, state: TState) => Partial<TState> | null)
const enhance = createComposer<{}>()
.withState('counter', 'setCounter', 0, (props, state) => {
// derived state logic here
// returning null doesn't make any changes to existing state
return null
})
.withHandler('handleIncrementClick', ({ counter, setCounter }) => (e: React.MouseEvent<any>) => {
e.preventDefault()
setCounter(counter + 1)
})
.build()
const Counter = enhance(({
counter,
handleIncrementClick
}) => (
<div>
Current value: {counter}
<a href="#" onClick={handleIncrementClick}>Increment</a>
</div>
))
Creates two additional props to the base component: a state value, and a
function to update that state value. The type of the prop is inferred from the
passed initial value which may be optionally specified using mapper function
from component's props. On top of that, there is an optional support for
getDerivedStateFromProps
lifecycle method specified by mapper function in
derivedStateFromProps argument which is then called on every component update.
Unlike other lifecycle methods, this one has to be defined here in the setState
decorator.
Note: The initial value is required for type inference to work. If you want the
state to be undefined you can explicitly type it. Ie. as (number) undefined
.
// .withPropFromContext(contextPropName: string, (contextPropValue: any) => TAdditionalProps)
const enhance = createComposer<{ type?: string }>()
.withPropFromContext('router', (router: Router) => ({ activeRoute: router.activeRoute }))
.build()
Decorator withPropFromContext()
allows to retrieve properties passed between
components by render context. The function automatically takes care of defining
necessary contextTypes for you.
The mapper function allows compiler to determine context type and to create possibly derived properties.
This library doesn't provide any means to actually create/pass context property down the render tree. It is recommended that you create class component for this.
// interface LifecycleMethods {
// componentWillMount?(): void
// componentDidMount?(): void
// componentWillReceiveProps?(nextProps: TResultingProps): void
// componentWillUpdate?(nextProps: TResultingProps, nextState: any): void
// componentDidUpdate?(prevProps: TResultingProps, nextState: any): void
// componentWillUnmount?(): void
// }
// .withLifecycle(lifecycleMethods: LifecycleMethods)
const enhance = createComposer<{ foo: string }>()
.withLifecycle({
componentDidMount() {
console.log(`Component mounted with ${this.props.foo}`)
}
})
.build()
Decorator withLifecycle
allows to specify React component lifecycle method.
This decorator is provided to maintain compatibility with Recompact. It is
recommended to use an actual class component implementation if you are in need
of lifecycle methods.
// .pure()
// .onlyUpdateForProps(props: Array<keyof TResultingProps>)
// .shouldUpdate(callback: (prevProps: TResultingProps, nextProps: TResultingProps) => boolean)
Decorator pure()
enhances component with shouldComponentUpdate
lifecycle
method which only allows updating component when identity of some prop changed.
Decorator onlyUpdateForProps()
enhances component with shouldComponentUpdate
lifecycle method which only allows updating component when identity of one of
the given props changed.
Decorator shouldUpdate()
enhances component with custom
shouldComponentUpdate
lifecycle method.
// .append(anotherComposer: ComponentDecoratorBuilder)
// .withDecorator(decorator: ComponentDecorator)
The append()
allows user to chain multiple composers (without the need to call
build()
). This may be useful for libraries which can combine multiple HoC into
single one (like recompact).
The withDecorator()
allows to enhance component by previously built decorator
(or by any other decorator not built by recompost).
You can use the template parameter to specify the type of injected properties. Here is an example:
import { injectIntl, InjectedIntl } from 'react-intl'
const enhance = createComposer()
.withDecorator<{ intl: InjectedIntl }>(injectIntl)
.build()
const MyComponent = ({ intl: { formatMessage }}) => (
<div>{formatMessage(...)}</div>
)
This can be useful for third-party components. However if you need to use some
existing decorators not built by recompost it is recommended to cast the
decorators into ComponentDecorator
. It is a special type which is used by
recompost to hold information about prop requirements. It takes 3 parameters:
ComponentDecorator<TInitialProps, TAdditionalProps, TOmittedProps>
Only the first two parameters are required. The TInitialProps
parameter can be
used to describe prop needs of the decorator. The TAdditionalProps
describes
which props has been added or should be replaced. The last optional parameter
TOmittedProps
allows to specify which props has been dropped. This is
necessary for safe decorator chaining.
Here is an example:
import { createComposer, ComponentDecorator } from 'recompost'
// This is for demonstrating some legacy code, please do not take this as an example of correct code
const someLegacyBadlyTypedDecorator = (component: React.Component<any>) => ({ name, ...props }: any) => React.createElement(component, { ...props, salutation: `Hey, ${name}!` })
// We retype that decorator so that the `.withDecorator()` can infer what it does
// Note: The any cast might not be necessary depending on the actual type of the decorator. This is just
// to be on the safe side for the demonstration purposes.
const typedDecorator: ComponentDecorator<
{ name: string }, // It requires `name`
{ salutation: string }, // It produces `salutation`
'name' // It removes `name`
> = someLegacyBadlyTypedDecorator as any
// ------
const enhance = createComposer<{ name: string }>
.withDecorator(typedDecorator)
.build()
// .withStatic(staticProps: { [k: string]: any })
// .withDisplayName(name: string)
If you want to add static properties to the resulting component in type safe
manner you can use withStatic()
. It accepts dictionary of properties which
will then be assigned as static properties when build()
is called.
Here is an example of themeable button which uses static properties to wrap component together with some theme enumeration.
import { createComposer } from 'recompost'
// Notice that we are not using `const enum` here. It is important so that TS will
// generate JS object from it.
export enum ButtonTheme {
Light = 'light',
Dark = 'dark'
}
export interface ButtonProps {
theme: ButtonTheme
}
const enhance = createComposer<ButtonProps>()
.withDefaultProps({
theme: ButtonTheme.Light,
})
.withStatic({
Themes: ButtonTheme // Using enum like a value here
})
.build()
const Button = enhance(
({ theme, children }) =>
<div className={`Button-${theme}`}>{children}</div>
)
Thanks to static properties you have all you need encapsulated within the
Button
component so that you can use it like this:
import { Button } from './button'
<Button theme={Button.Themes.Dark}>Click me!</Button>
The withDisplayName()
can be used as a shortcut for
withStatic({ displayName: /* ... */ })
.
The library was meant to be used together with stateless components (SFC). However if your use-case is better suited for class components you can do it like this:
import { createComposer } from 'recompost'
export interface SalutationProps {
name: string
}
const enhance = createComposer<SalutationProps>()
.withProps(({ name }) => ({
salutation: `Hello, ${name}`,
}))
.build()
const Salutation = enhance(props => {
// Create class component inside of enhance
// Use `typeof` operator to get the prop type
class ClassComponent extends React.Component<typeof props> {
render() {
const { salutation } = this.props
return (
<div>{salutation}</div>
)
}
}
// Return new element using the class component because the callback
// has to be valid SFC by itself.
return React.createElement(ClassComponent, props)
})
My thanks to the @belaczek for the logo :)