├── .eslintrc.js ├── .github └── workflows │ ├── publish.yml │ └── verify.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── babel.config.js ├── index.js ├── jest.config.js ├── package.json ├── src ├── __tests__ │ └── index.test.js └── index.js ├── webpack.config.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'helloitsjoe', 3 | }; 4 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: '16.16.x' 14 | registry-url: 'https://registry.npmjs.org' 15 | cache: yarn 16 | - run: yarn 17 | - run: npx helloitsjoe/release-toolkit publish 18 | env: 19 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches-ignore: 4 | - main 5 | 6 | jobs: 7 | verify: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | # Node 16.17 includes npm 8.15 which has a bug in npx 12 | node-version: [16.16.x, 18.x] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | cache: yarn 19 | - run: yarn 20 | - run: npx helloitsjoe/release-toolkit verify 21 | - run: yarn test 22 | - run: yarn lint 23 | - run: yarn coveralls 24 | env: 25 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | coverage 4 | dist 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.20](https://github.com/helloitsjoe/react-hooks-compose/releases/tag/v2.0.20) (2022-12-07) 2 | 3 | **Chore** 4 | 5 | - Bump decode-uri-component from 0.2.0 to 0.2.2 6 | - Bump loader-utils from 1.4.0 to 1.4.2 7 | 8 | ## [2.0.19](https://github.com/helloitsjoe/react-hooks-compose/releases/tag/v2.0.19) (2022-09-05) 9 | 10 | **Chore** 11 | 12 | - Include React 17 and 18 in `peerDependencies` 13 | - Remove `enzyme` and convert tests to React Testing Library 14 | - Upgrade dependencies 15 | 16 | ## [2.0.18](https://github.com/helloitsjoe/react-hooks-compose/releases/tag/v2.0.18) (2022-03-28) 17 | 18 | **Chore** 19 | 20 | Bump ansi-regex to 4.1.1 21 | 22 | ## [2.0.17](https://github.com/helloitsjoe/react-hooks-compose/releases/tag/v2.0.17) (2022-03-27) 23 | 24 | **Chore** 25 | 26 | Bump minimist to 1.2.6 27 | 28 | ## [2.0.16](https://github.com/helloitsjoe/react-hooks-compose/releases/tag/v2.0.16) (2021-09-26) 29 | 30 | **Chore** 31 | 32 | - Bump dependencies 33 | 34 | ## [2.0.15](https://github.com/helloitsjoe/react-hooks-compose/releases/tag/v2.0.15) (2021-05-30) 35 | 36 | **Chore** 37 | 38 | - Update deps 39 | 40 | ## [2.0.14](https://github.com/helloitsjoe/react-hooks-compose/releases/tag/v2.0.14) (2021-05-26) 41 | 42 | **Chore** 43 | 44 | - Use GitHub Actions 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-hooks-compose 2 | 3 | [![Build Status](https://travis-ci.com/helloitsjoe/react-hooks-compose.svg?branch=master)](https://travis-ci.com/helloitsjoe/react-hooks-compose) 4 | [![Coverage Status](https://coveralls.io/repos/github/helloitsjoe/react-hooks-compose/badge.svg?branch=master)](https://coveralls.io/github/helloitsjoe/react-hooks-compose?branch=master) 5 | [![NPM Version](https://img.shields.io/npm/v/react-hooks-compose?color=lightgray)](https://www.npmjs.com/package/react-hooks-compose) 6 | 7 | ## Installation 8 | 9 | ``` 10 | npm i react-hooks-compose 11 | ``` 12 | 13 | ## Why `react-hooks-compose`? 14 | 15 | `react-hooks-compose` provides an ergonomic way to decouple hooks from the components that use them. 16 | 17 | React Hooks are great. They encapsulate state logic and make it more reusable. But what if you have 18 | pure presentational components that you want to use with different state? What if you want to test 19 | your presentaional component in isolation? 20 | 21 | React Hooks invert the Container/Presenter pattern, putting the container inside the presenter. This 22 | makes it hard to use the same presentational component with different hooks, and clunky to test 23 | presentational components by themselves. 24 | 25 | One option: 26 | 27 | ```jsx 28 | import { Presenter } from './presenter'; 29 | import { useCustomHook } from './hooks'; 30 | 31 | const Wrapper = () => { 32 | const { foo, bar } = useCustomHook(); 33 | return ; 34 | }; 35 | 36 | export default Wrapper; 37 | ``` 38 | 39 | This works fine, but you end up with an extra component just to connect the hook to the Presenter. 40 | If you want to test the presenter in isolation, you have to export it separately. there must be a 41 | better way! 42 | 43 | ## Basic Usage 44 | 45 | `composeHooks` passes values from hooks as props, and allows you to pass any other props as normal. 46 | This allows you to export the hook, stateful component, and purely presentational component 47 | separately. 48 | 49 | ```jsx 50 | import composeHooks from 'react-hooks-compose'; 51 | 52 | const useForm = () => { 53 | const [name, setName] = useState(''); 54 | const onChange = e => setName(e.target.value); 55 | return { name, onChange }; 56 | }; 57 | 58 | // Other props (in this case `icon`) can be passed in separately 59 | const FormPresenter = ({ name, onChange, icon }) => ( 60 |
61 |
{icon}
62 |

Hello, {name}!

63 | 64 |
65 | ); 66 | 67 | export default composeHooks({ useForm })(FormPresenter); 68 | ``` 69 | 70 | You can think of `composeHooks` like `react-redux`'s `connect` HOC. For one thing, it creates an 71 | implicit container. You can think of the object passed into `composeHooks` as `mapHooksToProps`, 72 | similar to 73 | [the object form of `mapDispatchToProps`](https://daveceddia.com/redux-mapdispatchtoprops-object-form/). 74 | 75 | ### Compose multiple hooks: 76 | 77 | ```js 78 | const Presenter = ({ name, onChange, foo, bar, value }) => ( 79 |
80 |

Hello, {name}!

81 |

Context value is {value}

82 |

83 | foo is {foo}, bar is {bar} 84 |

85 | 86 |
87 | ); 88 | 89 | export default composeHooks({ 90 | useForm, 91 | useFooBar, 92 | value: () => useContext(MyContext), // Usage with `useContext` 93 | })(FormPresenter); 94 | ``` 95 | 96 | ### Usage with `useState` 97 | 98 | If you compose with `useState` directly (i.e. the prop is an array), the prop will remain an array 99 | and should be destructured before use: 100 | 101 | ```jsx 102 | const FormPresenter = ({ nameState: [name, setName] }) => ( 103 |
104 |

Hello, {name}!

105 | setName(e.target.value)} /> 106 |
107 | ); 108 | 109 | export default composeHooks({ 110 | nameState: () => useState('Calvin'), 111 | })(FormPresenter); 112 | ``` 113 | 114 | ### Usage with `useEffect` 115 | 116 | `useEffect` is supported - the most common usage would be in a custom hook. For example: 117 | 118 | ```js 119 | const usePostData = data => { 120 | const [postStatus, setPostStatus] = useState(SUCCESS); 121 | 122 | useEffect(() => { 123 | setPostStatus(LOADING); 124 | postData(data).then(() => { 125 | setPostStatus(SUCCESS); 126 | }).catch(err => { 127 | setPostStatus(ERROR); 128 | }); 129 | }, [data]); 130 | 131 | return { postStatus }; 132 | }; 133 | 134 | const App = ({ postStatus }) => { ... }; 135 | 136 | export default compose({ usePostData })(App); 137 | ``` 138 | 139 | ### Pass in props for initial values 140 | 141 | If your hooks need access to props to set their initial values, you can pass a function to 142 | `composeHooks`. This function receives `props` as an argument, and should always return an object: 143 | 144 | ```jsx 145 | const useForm = (initialValue = '') => { 146 | const [value, setValue] = useState(initialValue); 147 | const onChange = e => setValue(e.target.value); 148 | return { value, onChange }; 149 | }; 150 | 151 | const FormContainer = composeHooks(props => ({ 152 | useForm: () => useForm(props.initialValue), 153 | }))(FormPresenter); 154 | 155 | ; 156 | ``` 157 | 158 | ## Testing 159 | 160 | `composeHooks` is great for testing. Any props you pass in will override the hooks values, so you 161 | can test the presenter and container with a single export: 162 | 163 | ```jsx 164 | // band-member.js 165 | const BandMember = ({singer, onClick}) => {...} // <-- Presenter 166 | 167 | export default composeHooks({ useName })(BandMember); 168 | 169 | // band-member.test.js 170 | it('returns Joey if singer is true', () => { 171 | // Pass in a `singer` boolean as with any presentational component. 172 | // Containers don't usually allow this. 173 | const {getByLabelText} = render(); 174 | expect(getByLabelText('Name').textContent).toBe('Joey'); 175 | }); 176 | 177 | it('updates name to Joey when Get Singer button is clicked', () => { 178 | // If you don't pass in props, the component will use the hooks provided 179 | // in the module. In this case, `useName` returns `singer` and `onClick`. 180 | const {getByLabelText} = render(); 181 | expect(getByLabelText('Name').textContent).toBe('Johnny'); 182 | fireEvent.click(getByText('Get Singer')); 183 | expect(getByLabelText('Name').textContent).toBe('Joey'); 184 | }) 185 | ``` 186 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const makeBabelConfig = require('babel-react-simple'); 2 | 3 | module.exports = makeBabelConfig(); 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const composeHooks = require('./dist/main'); 2 | 3 | module.exports = composeHooks; 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom' 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hooks-compose", 3 | "version": "2.0.20", 4 | "description": "Compose React Hooks", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "test": "jest --coverage", 8 | "release": "npx github:helloitsjoe/release-toolkit release", 9 | "lint": "eslint ./src", 10 | "coveralls": "cat ./coverage/lcov.info | coveralls", 11 | "watch": "webpack --watch", 12 | "build": "webpack", 13 | "prepublishOnly": "rm -rf dist && yarn build && yarn test --silent" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/helloitsjoe/react-hooks-compose.git" 18 | }, 19 | "keywords": [ 20 | "react", 21 | "hooks", 22 | "compose", 23 | "composition", 24 | "testing" 25 | ], 26 | "author": "Joe Boyle", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/helloitsjoe/react-hooks-compose/issues" 30 | }, 31 | "files": [ 32 | "/dist", 33 | "README.md" 34 | ], 35 | "husky": { 36 | "hooks": { 37 | "pre-push": "npm t -- --silent" 38 | } 39 | }, 40 | "homepage": "https://github.com/helloitsjoe/react-hooks-compose#readme", 41 | "peerDependencies": { 42 | "react": "^16.8 || ^17 || ^18" 43 | }, 44 | "devDependencies": { 45 | "@babel/core": "^7.11.6", 46 | "@testing-library/react": "^13.4.0", 47 | "babel-eslint": "^10.0.3", 48 | "babel-loader": "^8.0.6", 49 | "babel-react-simple": "^1.0.2", 50 | "coveralls": "^3.0.9", 51 | "eslint": "^6.8.0", 52 | "eslint-config-helloitsjoe": "^1.2.2", 53 | "eslint-plugin-import": "^2.19.1", 54 | "eslint-plugin-jsx-a11y": "^6.2.3", 55 | "eslint-plugin-react": "^7.17.0", 56 | "eslint-plugin-react-hooks": "^4.2.0", 57 | "husky": "^3.1.0", 58 | "jest": "^29.0.2", 59 | "jest-environment-jsdom": "^29.0.2", 60 | "prettier": "^1.19.1", 61 | "react": "^18", 62 | "react-dom": "^18", 63 | "webpack": "^4.41.4", 64 | "webpack-cli": "^3.3.10", 65 | "webpack-simple": "^1.5.2" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | /* eslint-disable react/button-has-type */ 3 | import React, { useState, useContext, useEffect } from 'react'; 4 | import { render, screen, fireEvent, waitFor } from '@testing-library/react'; 5 | import composeHooks from '../index'; 6 | 7 | const INITIAL_COUNT = 0; 8 | const INITIAL_VALUE = 'hi'; 9 | 10 | const useCount = () => { 11 | const [count, setCount] = useState(INITIAL_COUNT); 12 | const increment = () => setCount(c => c + 1); 13 | const decrement = () => setCount(c => c - 1); 14 | return { count, increment, decrement }; 15 | }; 16 | 17 | const useChange = (initialValue = INITIAL_VALUE) => { 18 | const [value, setValue] = useState(initialValue); 19 | const onChange = e => setValue(e.target.value); 20 | return { value, onChange }; 21 | }; 22 | 23 | const useUseState = () => useState(INITIAL_COUNT); 24 | 25 | const TestComponent = ({ text }) =>
{text}
; 26 | 27 | TestComponent.defaultProps = { 28 | text: 'Test' 29 | }; 30 | 31 | test('passes custom hooks to component', () => { 32 | const MockComponent = jest.fn(() =>
Test
); 33 | const Container = composeHooks({ useCount, useChange })(MockComponent); 34 | render(); 35 | expect(MockComponent.mock.calls[0][0]).toEqual({ 36 | count: INITIAL_COUNT, 37 | value: INITIAL_VALUE, 38 | increment: expect.any(Function), 39 | decrement: expect.any(Function), 40 | onChange: expect.any(Function) 41 | }); 42 | }); 43 | 44 | test('passes props to component', () => { 45 | const MockComponent = jest.fn(() =>
Test
); 46 | const Container = composeHooks({ useChange })(MockComponent); 47 | render(); 48 | expect(MockComponent.mock.calls[0][0].foo).toBe('bar'); 49 | }); 50 | 51 | test('hooks work as expected', () => { 52 | const Component = ({ value, onChange }) => ( 53 | 57 | ); 58 | const Container = composeHooks({ useChange })(Component); 59 | render(); 60 | expect(screen.getByLabelText(/testing/i).value).toBe(INITIAL_VALUE); 61 | fireEvent.change(screen.getByLabelText(/testing/i), { 62 | target: { value: 'new' } 63 | }); 64 | expect(screen.getByLabelText(/testing/i).value).toBe('new'); 65 | }); 66 | 67 | test('works with useContext', () => { 68 | const TestContext = React.createContext(); 69 | const Component = ({ value }) =>
{value}
; 70 | const Container = composeHooks({ 71 | value: function ContextFn() { 72 | return useContext(TestContext); 73 | } 74 | })(Component); 75 | render( 76 | 77 | 78 | 79 | ); 80 | expect(screen.getByText(/hello/i)).toBeTruthy(); 81 | }); 82 | 83 | test('works with custom hook that returns array', () => { 84 | const Component = ({ simpleHook }) => { 85 | const [count, setCount] = simpleHook; 86 | return ; 87 | }; 88 | const Container = composeHooks({ simpleHook: useUseState })(Component); 89 | render(); 90 | expect(screen.getByRole('button').textContent).toBe(INITIAL_COUNT.toString()); 91 | fireEvent.click(screen.getByRole('button')); 92 | expect(screen.getByRole('button').textContent).toBe( 93 | (INITIAL_COUNT + 1).toString() 94 | ); 95 | }); 96 | 97 | test('works with custom hook that returns single value', () => { 98 | // Check single function value 99 | let outerFoo; 100 | const useFoo = () => { 101 | const [foo, setFoo] = useState('before'); 102 | outerFoo = foo; 103 | return setFoo; 104 | }; 105 | // Check single value 106 | const useBar = () => { 107 | const [bar] = useState('Click me'); 108 | return bar; 109 | }; 110 | const Component = ({ setFoo, bar }) => ( 111 | 112 | ); 113 | const Container = composeHooks({ setFoo: useFoo, bar: useBar })(Component); 114 | render(); 115 | expect(outerFoo).toBe('before'); 116 | fireEvent.click(screen.getByRole('button')); 117 | expect(outerFoo).toBe('after'); 118 | }); 119 | 120 | test('can pass props to hooks via function', () => { 121 | const TEST_VALUE = 'test-value'; 122 | const Component = ({ value }) => value; 123 | const Container = composeHooks(props => ({ 124 | useChange: () => useChange(props.initialValue) 125 | }))(Component); 126 | render(); 127 | expect(screen.getByText(TEST_VALUE)).toBeTruthy(); 128 | }); 129 | 130 | test('useEffect from custom hook', () => { 131 | const Component = ({ value }) => value; 132 | const useCustomHook = () => { 133 | const [value, setValue] = useState('before'); 134 | useEffect(() => { 135 | setTimeout(() => { 136 | setValue('after'); 137 | }, 50); 138 | }, []); 139 | return { value }; 140 | }; 141 | const Container = composeHooks({ useCustomHook })(Component); 142 | const { container } = render(); 143 | expect(container.textContent).toBe('before'); 144 | return waitFor(() => { 145 | expect(container.textContent).toBe('after'); 146 | }); 147 | }); 148 | 149 | describe('Edge cases', () => { 150 | it('returns component if no hooks', () => { 151 | const Container = composeHooks()(TestComponent); 152 | render(); 153 | expect(screen.getByText(/some text/i)).toBeTruthy(); 154 | }); 155 | 156 | it('throws if no component', () => { 157 | expect(() => composeHooks()()).toThrowErrorMatchingInlineSnapshot( 158 | `"Component must be provided to compose"` 159 | ); 160 | }); 161 | }); 162 | 163 | describe('React.memo', () => { 164 | // Note that using shorthand like composeHooks({ useOne: () => useState() }) will 165 | // not work, because the array returned from useState will break strict equality. 166 | // TODO: Document these cases! 167 | 168 | const TestContext = React.createContext({}); 169 | const TestProvider = ({ children }) => { 170 | const [nameOne, setNameOne] = useState(''); 171 | const [nameTwo, setNameTwo] = useState(''); 172 | 173 | return ( 174 | 177 | {children} 178 | 179 | ); 180 | }; 181 | const withContext = Component => props => ( 182 | 183 | 184 | 185 | ); 186 | 187 | const useOne = () => { 188 | const { nameOne } = useContext(TestContext); 189 | return { nameOne }; 190 | }; 191 | const useTwo = () => { 192 | const { nameTwo } = useContext(TestContext); 193 | return { nameTwo }; 194 | }; 195 | 196 | let rendersOne = 0; 197 | let rendersTwo = 0; 198 | let rendersParent = 0; 199 | 200 | const ChildOne = ({ one, nameOne }) => { 201 | rendersOne++; 202 | return ( 203 | <> 204 | {one} 205 |
{nameOne}
206 | 207 | ); 208 | }; 209 | const ChildTwo = ({ two, nameTwo }) => { 210 | rendersTwo++; 211 | return ( 212 | <> 213 | {two} 214 |
{nameTwo}
215 | 216 | ); 217 | }; 218 | const InputChild = () => { 219 | const { setNameOne, setNameTwo } = useContext(TestContext); 220 | return ( 221 | <> 222 | 226 | 230 | 231 | ); 232 | }; 233 | 234 | const HookedOne = composeHooks({ useOne })(ChildOne); 235 | const HookedMemoTwo = composeHooks({ useTwo })(React.memo(ChildTwo)); 236 | 237 | const Parent = withContext(() => { 238 | rendersParent++; 239 | const [, setCount] = useState(0); 240 | const [one, setOne] = useState(0); 241 | const [two, setTwo] = useState(0); 242 | 243 | return ( 244 | <> 245 | 246 | 247 | 248 | 251 | 254 | 255 | 256 | ); 257 | }); 258 | 259 | beforeEach(() => { 260 | rendersOne = 0; 261 | rendersTwo = 0; 262 | rendersParent = 0; 263 | }); 264 | 265 | it('renders memoized child when props update', () => { 266 | render(); 267 | expect(rendersOne).toBe(1); 268 | expect(rendersTwo).toBe(1); 269 | 270 | fireEvent.click(screen.getByText('Update Child Two Props')); 271 | expect(rendersOne).toBe(2); 272 | expect(rendersTwo).toBe(2); 273 | }); 274 | 275 | it('does NOT re-render memoized child when child 2 props update', () => { 276 | render(); 277 | fireEvent.click(screen.getByText('Update Child One Props')); 278 | expect(rendersOne).toBe(2); 279 | expect(rendersTwo).toBe(1); 280 | }); 281 | 282 | it('does NOT render memoized child when non-subscribed context value updates', () => { 283 | render(); 284 | fireEvent.change(screen.getByLabelText(/name one/i), { 285 | target: { value: 'Calvin' } 286 | }); 287 | expect(rendersOne).toBe(2); 288 | expect(rendersTwo).toBe(1); 289 | // All updates via Context so parent should not rerender 290 | expect(rendersParent).toBe(1); 291 | expect(screen.getByTestId('one').textContent).toBe('Calvin'); 292 | expect(screen.getByTestId('two').textContent).toBe(''); 293 | }); 294 | 295 | it('renders memoized child when subscribed context value changes', () => { 296 | const { getByTestId } = render(); 297 | fireEvent.change(screen.getByLabelText(/name two/i), { 298 | target: { value: 'Hobbes' } 299 | }); 300 | expect(rendersOne).toBe(2); 301 | expect(rendersTwo).toBe(2); 302 | // All updates via Context so parent should not rerender 303 | expect(rendersParent).toBe(1); 304 | expect(getByTestId('one').textContent).toBe(''); 305 | expect(getByTestId('two').textContent).toBe('Hobbes'); 306 | }); 307 | }); 308 | 309 | describe('Naming collisions', () => { 310 | const useOne = () => ({ text: 'one' }); 311 | const useTwo = () => ({ text: 'two' }); 312 | const useNumber = () => ({ number: 1 }); 313 | const useBool = () => ({ bool: true }); 314 | const useNull = () => ({ null: 'not-null' }); 315 | const origWarn = console.warn; 316 | 317 | beforeEach(() => { 318 | console.warn = jest.fn(() => {}); 319 | }); 320 | 321 | afterEach(() => { 322 | jest.clearAllMocks(); 323 | console.warn = origWarn; 324 | }); 325 | 326 | it('if prop and hook names collide, props win (not including defaultProps)', () => { 327 | const MockComponent = jest.fn(() =>
Test
); 328 | const Container = composeHooks({ useOne, useNumber, useBool, useNull })( 329 | MockComponent 330 | ); 331 | // Check falsy values, should warn for everything but undefined 332 | render(); 333 | const [first, second, third, fourth] = console.warn.mock.calls; 334 | expect(first[0]).toMatchInlineSnapshot( 335 | `"prop 'text' exists, overriding with value: ''"` 336 | ); 337 | expect(second[0]).toMatchInlineSnapshot( 338 | `"prop 'number' exists, overriding with value: '0'"` 339 | ); 340 | expect(third[0]).toMatchInlineSnapshot( 341 | `"prop 'bool' exists, overriding with value: 'false'"` 342 | ); 343 | expect(fourth[0]).toMatchInlineSnapshot( 344 | `"prop 'null' exists, overriding with value: 'null'"` 345 | ); 346 | const firstMockCall = MockComponent.mock.calls[0][0]; 347 | expect(firstMockCall.text).toBe(''); 348 | expect(firstMockCall.number).toBe(0); 349 | expect(firstMockCall.bool).toBe(false); 350 | expect(firstMockCall.null).toBe(null); 351 | }); 352 | 353 | it('hooks override defaultProps', () => { 354 | const Container = composeHooks({ useOne })(TestComponent); 355 | const { container } = render(); 356 | expect(container.textContent).toBe('one'); 357 | const { container: rawContainer } = render(); 358 | expect(rawContainer.textContent).toBe('Test'); 359 | }); 360 | 361 | it('if multiple hook value names collide, last one wins', () => { 362 | const Container = composeHooks({ useOne, useTwo })(TestComponent); 363 | render(); 364 | expect(console.warn.mock.calls[0][0]).toMatchInlineSnapshot( 365 | `"prop 'text' exists, overriding with value: 'two'"` 366 | ); 367 | expect(screen.queryByText('two')).toBeTruthy(); 368 | expect(screen.queryByText('text')).not.toBeTruthy(); 369 | }); 370 | }); 371 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const composeHooks = hooks => Component => { 4 | if (!Component) { 5 | throw new Error('Component must be provided to compose'); 6 | } 7 | 8 | if (!hooks) { 9 | return Component; 10 | } 11 | 12 | return props => { 13 | const hooksObject = typeof hooks === 'function' ? hooks(props) : hooks; 14 | 15 | // Flatten values from all hooks to a single object 16 | const hooksProps = Object.entries(hooksObject).reduce((acc, [hookKey, hook]) => { 17 | let hookValue = hook(); 18 | 19 | if (Array.isArray(hookValue) || typeof hookValue !== 'object') { 20 | hookValue = { [hookKey]: hookValue }; 21 | } 22 | 23 | Object.entries(hookValue).forEach(([key, value]) => { 24 | const duplicate = acc[key] ? value : props[key]; 25 | 26 | if (typeof duplicate !== 'undefined') { 27 | console.warn(`prop '${key}' exists, overriding with value: '${duplicate}'`); 28 | } 29 | acc[key] = value; 30 | }); 31 | 32 | return acc; 33 | }, {}); 34 | 35 | return ; 36 | }; 37 | }; 38 | 39 | export default composeHooks; 40 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { makeWebpackConfig } = require('webpack-simple'); 2 | 3 | const config = makeWebpackConfig({ 4 | entry: './src/index.js', 5 | output: { 6 | path: `${__dirname}/dist/`, 7 | filename: `main.js`, 8 | library: 'react-hooks-compose', 9 | libraryTarget: 'umd', 10 | }, 11 | mode: 'production', 12 | devtool: 'source-map', 13 | externals: 'react', 14 | }); 15 | 16 | module.exports = config; 17 | --------------------------------------------------------------------------------