UNPKG

react-magnetic-di

Version:
217 lines (165 loc) 9.91 kB
<p align="center"> <img src="https://user-images.githubusercontent.com/84136/83958267-1c8f7f00-a8b3-11ea-9725-1d3530af5f8d.png" alt="react-magnetic-di logo" height="150" /> </p> <h1 align="center">react-magnetic-di</h1> <p align="center"> <a href="https://www.npmjs.com/package/react-magnetic-di"><img src="https://img.shields.io/npm/v/react-magnetic-di.svg"></a> <a href="https://bundlephobia.com/result?p=react-magnetic-di"><img src="https://img.shields.io/bundlephobia/minzip/react-magnetic-di.svg" /></a> <a href="https://codecov.io/gh/albertogasparin/react-magnetic-di"><img src="https://codecov.io/gh/albertogasparin/react-magnetic-di/branch/master/graph/badge.svg" /> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg"></a> <!--a href="CONTRIBUTING"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" /></a--> </p> A new take for dependency injection / dependency replacement in React for your tests, storybooks and even experiments in production. - Close-to-zero performance overhead on dev/testing - **Zero** performance overhead on production (code gets stripped unless told otherwise) - Works with any kind of functions/classes (not only components) and in both class and functional components - Replaces dependencies at any depth of the React tree - Allows selective injection - Enforces separation of concerns, keeps your component API clean - Just uses Context, it does not mess up with React internals or modules/require ## Philosophy Dependency injection and component injection is not a new topic. Especially the ability to provide a custom implementation of a component/hook while testing or writing storybooks and examples it is extremely valuable. `react-magnetic-di` takes inspiration from decorators, and with a touch of Babel magic and React Context allows you to optionally override "marked" dependencies inside your components so you can swap implementations only when needed. ## Usage ```sh npm i react-magnetic-di # or yarn add react-magnetic-di ``` ### Adding babel plugin (or using macro) Edit your Babel config file (`.babelrc` / `babel.config.js` / ...) and add: ```js // ... other stuff like presets plugins: [ // ... other plugins 'react-magnetic-di/babel-plugin', ], ``` If you are using Create React App or babel macros, you don't need the babel plugin: just import the methods from `react-magnetic-di/macro` (see next example). ### Using injection replacement in your components Given a component with complex UI interaction or data dependencies, like a Modal or an Apollo Query, we want to easily be able to integration test it. To achieve that, we "mark" such dependencies in the `render` function of the class component: ```jsx import React, { Component } from 'react'; import { di } from 'react-magnetic-di'; // or import { di } from 'react-magnetic-di/macro'; import { Modal } from 'material-ui'; import { Query } from 'react-apollo'; class MyComponent extends Component { render() { // that's all is needed to "mark" these variables as injectable di(Modal, Query); return ( <Modal> <Query>{({ data }) => data && 'Done!'}</Query> </Modal> ); } } ``` Or on our functional component with hooks: ```jsx function MyComponent() { // "mark" any type of function/class as injectable di(Modal, useQuery); const { data } = useQuery(); return <Modal>{data && 'Done!'}</Modal>; } ``` ### Leveraging dependency replacement in tests and storybooks In the unit/integration tests or storybooks we can create a new injectable implementation and wrap the component with `DiProvider` to override such dependency: ```jsx import React from 'react'; import { DiProvider, injectable } from 'react-magnetic-di'; import { Modal } from 'material-ui'; import { useQuery } from 'react-apollo-hooks'; // injectable() needs the original implementation as first argument // and the replacement implementation as second const ModalOpenDi = injectable(Modal, () => <div />); const useQueryDi = injectable(useQuery, () => ({ data: null })); // test-enzyme.js it('should render with enzyme', () => { const container = mount(<MyComponent />, { wrappingComponent: DiProvider, wrappingComponentProps: { use: [ModalOpenDi, useQueryDi] }, }); expect(container.html()).toMatchSnapshot(); }); // test-testing-library.js it('should render with react-testing-library', () => { const { container } = render(<MyComponent />, { wrapper: (p) => <DiProvider use={[ModalOpenDi, useQueryDi]} {...p} />, }); expect(container).toMatchSnapshot(); }); // story.js storiesOf('Modal content', module).add('with text', () => ( <DiProvider use={[ModalOpenDi, useQueryDi]}> <MyComponent /> </DiProvider> )); ``` In the example above we replace all `Modal` and `useQuery` dependencies across all components in the tree with the custom versions. If you want to replace dependencies **only** for a specific component (or set of components) you can use the `target` prop: ```jsx // story.js storiesOf('Modal content', module).add('with text', () => ( <DiProvider target={[MyComponent, MyOtherComponent]} use={[ModalOpenDi]}> <DiProvider target={MyComponent} use={[useQueryDi]}> <MyComponent /> <MyOtherComponent> </DiProvider> </DiProvider> )); ``` In the example above `MyComponent` will have both `ModalOpen` and `useQuery` replaced while `MyOtherComponent` only `ModalOpen`. Be aware that `target` needs an **actual component** declaration to work, so will not work in cases where the component is fully anonymous (eg: `export default () => ...` or `forwardRef(() => ...)`). The library also provides a `withDi` HOC in case you want to export components with dependencies alredy injected: ```jsx import React from 'react'; import { withDi, injectable } from 'react-magnetic-di'; import { Modal } from 'material-ui'; import { MyComponent } from './my-component'; const ModalOpenDi = injectable(Modal, () => <div />); export default withDi(MyComponent, [ModalOpenDi]); ``` `withDi` supports the same API as `DiProvider`, where `target` is the third argument of the HOC `withDi(MyComponent, [Modal], MyComponent)` in case you want to limit injection to a specific component only. ### Configuration Options #### Enable dependency replacement on production (or custom env) By default dependency replacement is enabled on `development` and `test` environments only, which means `di(...)` is removed on production builds. If you want to allow injection on production too (or on a custom env) you can use the `forceEnable` option: ```js // In your .babelrc / babel.config.js // ... other stuff like presets plugins: [ // ... other plugins ['react-magnetic-di/babel-plugin', { forceEnable: true }], ], ``` ## Eslint plugin and rules In order to enforce better practices, this package exports some eslint rules: | rule | description | options | | ------------------- | ---------------------------------------------------------------------------------------- | ------------------------ | | `order` | enforces `di(...)` to be the top of the block, to reduce chances of partial replacements | - | | `exhaustive-inject` | enforces all external components/hooks being used to be marked as injectable. | `ignore`: array of names | | `no-duplicate` | prohibits marking the same dependency as injectable more than once in the same block | - | | `no-extraneous` | enforces dependencies to be consumed in the scope, to prevent unused variables | - | | `sort-dependencies` | require injectable dependencies to be sorted | - | The rules are exported from `react-magnetic-di/eslint-plugin`. Unfortunately Eslint does not allow plugins that are not npm packages, so rules needs to be imported via other means for now. ## Current limitations - Does not support Enzyme shallow ([due to shallow not fully supporting context](https://github.com/enzymejs/enzyme/issues/2176)). If you wish to shallow anyway, you could mock `di` and manually return the array of mocked dependencies, but it is not recommended. - Does not support dynamic `use` and `target` props (changes are ignored) - Officially supports injecting only functions/classes. If you need to inject some other data types, create a simple getter and use that as dependency. - Does not replace default props (or default parameters in general): so dependencies provided as default parameters (eg `function MyComponent ({ modal = Modal }) { ... }`) will be ignored. If you accept the dependency as prop/argument you should inject it via prop/argument, as having a double injection strategy is just confusing. ## Can it be used without Babel plugin? Yes, but you will have to handle variable assignment yourself, which is a bit verbose. In this mode `di` needs an array of dependencies as first argument and the component, or `null`, as second (to make `target` behaviour work). Moreover, `di` won't be removed on prod builds and ESLint rules are not currently compatible with this mode. ```js import React, { Component } from 'react'; import { di } from 'react-magnetic-di'; import { Modal as ModalInj } from 'material-ui'; import { useQuery as useQueryInj } from 'react-apollo'; function MyComponent() { const [Modal, useQuery] = di([ModalInj, useQueryInj], MyComponent); const { data } = useQuery(); return <Modal>{data && 'Done!'}</Modal>; } ``` ## Contributing To test your changes you can run the examples (with `npm run start`). Also, make sure you run `npm run preversion` before creating you PR so you will double check that linting, types and tests are fine.