Skip to content

adam-stanek/recompost

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

69 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Recompost

Typed binding for recompose/recompact functions with fluent interface builder pattern.

Why?

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.

How to use it

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.

Adding 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.

Omitting props

// .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.

Default props

// .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.

Handlers

// .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()

State

// .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.

Context

// .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.

Lifecycle

// 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.

Component update optimization

// .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.

Custom decorators and chaining

// .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()

Static props

// .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: /* ... */ }).

Using it together with class components

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)
})

Who uses Recompost

Credits

My thanks to the @belaczek for the logo :)

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published