Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Memo HOC #18

Merged
merged 4 commits into from
Mar 11, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- [`getWindowFromComponent`](getwindowfromcomponent.md)
- [`hoistNonReactStatics`](hoistnonreactstatics.md)
- [`isReactComponent`](isreactcomponent.md)
- [`memo`](memo.md)
- [`perf`](perf.md)
- [`renderSpy`](renderSpy.md)
- [`withSafeSetState`](withsafesetstate.md)
Expand Down
44 changes: 44 additions & 0 deletions docs/memo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# memo(Component, areEqual, lifecycleHooks)

A higher-order component that memoizes and prevents re-renders of components if the props are unchanged.

For additional documentation, check out [React.memo](https://reactjs.org/docs/react-api.html#reactmemo)

## Arguments

| Argument | Type | Description |
| :--------------- | :------------------------------- | :---------------------------------------- |
| `Component` | `React.Component` | The React component. |
| `areEqual` | `Function(prevProps, nextProps)` | Comparison function. Returns a `boolean`. |
| `lifecycleHooks` | `Object` | Class-like lifecycle callback hooks |

## Returns

`React.Component`: The React component.

## Examples

```jsx
import React from 'react'
import memo from '@helpscout/react-utils/dist/memo'

const Kip = props => <div {...props} />
const MemoizedKip = memo(Kip)
```

Alternatively...

```jsx
import React from 'react'
import memo from '@helpscout/react-utils/dist/memo'

const MemoizedKip = memo(function Kip(props) {
return <div {...props} />
})
```

## Lifecycle hooks

### componentDidUpdate(prevProps, nextProps)

This callback hook fires if the memoized component updates.
133 changes: 133 additions & 0 deletions src/__tests__/memo.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import React from 'react'
import { mount } from 'enzyme'
import memo from '../memo'

describe('memo', () => {
describe('Wrapping', () => {
test('Can wrap a SFC', () => {
const Compo = props => <div {...props} />
const MemoCompo = memo(Compo)

const wrapper = mount(<MemoCompo>Hello</MemoCompo>)

expect(Compo).not.toEqual(MemoCompo)
expect(wrapper.text()).toBe('Hello')
})

test('Can wrap a React.PureComponent', () => {
class Compo extends React.PureComponent {
render() {
return <div {...this.props} />
}
}
const MemoCompo = memo(Compo)

const wrapper = mount(<MemoCompo>Hello</MemoCompo>)

expect(Compo).not.toEqual(MemoCompo)
expect(wrapper.text()).toBe('Hello')
})

test('Can wrap a React.Component', () => {
class Compo extends React.Component {
render() {
return <div {...this.props} />
}
}
const MemoCompo = memo(Compo)

const wrapper = mount(<MemoCompo>Hello</MemoCompo>)

expect(Compo).not.toEqual(MemoCompo)
expect(wrapper.text()).toBe('Hello')
})
})

describe('Statics', () => {
test('Hoists statics from the wrapped component', () => {
const Compo = props => <div {...props} />
Compo.Sub = props => <div {...props} />
const MemoCompo = memo(Compo)

const wrapper = mount(<MemoCompo.Sub>Hello</MemoCompo.Sub>)

expect(wrapper.text()).toBe('Hello')
})

test('Uses displayName, if defined', () => {
const Compo = props => <div {...props} />
Compo.displayName = 'Hello'
const MemoCompo = memo(Compo)

const wrapper = mount(<MemoCompo>Hello</MemoCompo>)

expect(wrapper.find('Hello').length).toBeTruthy()
})
})

describe('Memoize', () => {
test('Does not re-render component if prop does not change', () => {
const spy = jest.fn()
const Compo = props => <div {...props} />
const MemoCompo = memo(Compo, null, {
componentDidUpdate: spy,
})
MemoCompo.displayName = 'Memo'

const wrapper = mount(<MemoCompo title="Hello">Hello</MemoCompo>)
const initialComponent = wrapper.find('Memo')

expect(wrapper.text()).toBe('Hello')
expect(spy).toHaveBeenCalledTimes(1)

wrapper.setProps({ title: 'Hello' })
wrapper.setProps({ children: 'Hello' })
expect(spy).toHaveBeenCalledTimes(1)

expect(initialComponent).toEqual(wrapper.find('Memo'))
})

test('Re-renders component on prop change', () => {
const spy = jest.fn()
const Compo = props => <div {...props} />
const MemoCompo = memo(Compo, null, {
componentDidUpdate: spy,
})
MemoCompo.displayName = 'Memo'

const wrapper = mount(<MemoCompo title="Hello">Hello</MemoCompo>)
const initialComponent = wrapper.find('Memo')

expect(wrapper.text()).toBe('Hello')
expect(spy).toHaveBeenCalledTimes(1)

wrapper.setProps({ title: 'Hello' })
wrapper.setProps({ title: 'There' })
expect(spy).toHaveBeenCalledTimes(2)

expect(initialComponent).not.toEqual(wrapper.find('Memo'))
})

test('Re-renders component if new prop value existed previously', () => {
const spy = jest.fn()
const Compo = props => <div {...props} />
const MemoCompo = memo(Compo, null, {
componentDidUpdate: spy,
})

const wrapper = mount(<MemoCompo title="Hello">Hello</MemoCompo>)

expect(wrapper.text()).toBe('Hello')
expect(spy).toHaveBeenCalledTimes(1)

wrapper.setProps({ title: 'There' })
expect(spy).toHaveBeenCalledTimes(2)

wrapper.setProps({ title: 'There' })
expect(spy).toHaveBeenCalledTimes(2)

wrapper.setProps({ title: 'Hello' })
expect(spy).toHaveBeenCalledTimes(3)
})
})
})
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export { default as hoistNonReactStatics } from './hoistNonReactStatics'
export { default as isPropValid } from './isPropValid'
export { default as isReactComponent } from './isReactComponent'
export { default as isType } from './isType'
export { default as memo } from './memo'
export { default as perf } from './perf'
export { default as reactVersion } from './reactVersion'
export { default as renderSpy } from './renderSpy'
Expand Down
53 changes: 53 additions & 0 deletions src/memo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as React from 'react'
import { ComponentType } from 'react'
import hoistNonReactStatics from './hoistNonReactStatics'
import shallowEqual, { CompareFunction } from './shallowEqual'
import wrapComponentName from './wrapComponentName'
import { isDefined, isFunction, noop } from './utils'

const defaultLifeCycleHooks = {
componentDidUpdate: noop,
}

// Enhancement + polyfill for React.memo (v16.6)
// https://reactjs.org/docs/react-api.html#reactmemo
export function memo<T>(
Component: ComponentType<T>,
areEqual: CompareFunction = shallowEqual,
lifecycleHooks: any = defaultLifeCycleHooks,
): ComponentType<T> {
// Cache the initial props + component
let prevProps = {}
let memoizedComponent: JSX.Element | undefined

// Merge/extract options
const lifecycles = { ...defaultLifeCycleHooks, ...lifecycleHooks }
const { componentDidUpdate } = lifecycles
const shouldComponentUpdate = isFunction(areEqual) ? areEqual : shallowEqual

const wrappedComponent = nextProps => {
// shouldComponentUpdate test for prop changes
if (
isDefined(memoizedComponent) &&
shouldComponentUpdate(prevProps, nextProps)
) {
return memoizedComponent
}

// Update the prop cache
prevProps = nextProps
// Update the memozied component
memoizedComponent = <Component {...nextProps} />

// Call the componentDidUpdate hook
componentDidUpdate(prevProps, nextProps)

return memoizedComponent
}

wrappedComponent.displayName = wrapComponentName(Component, 'memo')

return hoistNonReactStatics(wrappedComponent, Component)
}

export default memo
17 changes: 17 additions & 0 deletions src/shallowEqual.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export type CompareFunction = (
prev: { [key: string]: any },
next: { [key: string]: any },
) => boolean

export const shallowEqual = (
prev: { [key: string]: any },
next: { [key: string]: any },
): boolean => {
for (let key in next) {
if (next[key] !== prev[key]) return false
}

return true
}

export default shallowEqual
8 changes: 6 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ export function isFunction<T>(value: unknown): value is Function {
return typeOf(value, 'function')
}

export function isNumber<T>(value: unknown): value is Number {
export function isNumber<T>(value: unknown): value is number {
return typeOf(value, 'number')
}

export function isString<T>(value: unknown): value is String {
export function isString<T>(value: unknown): value is string {
return typeOf(value, 'string')
}

Expand All @@ -51,3 +51,7 @@ export function createUniqueIndexFactory(start: number = 1) {
let index = typeof start === 'number' ? start : 1
return (): number => index++
}

export function noop() {
return undefined
}