Nocturnal Pumpkin Maelstrom

    react-magnetic-di
    TypeScript icon, indicating that this package has built-in type declarations

    2.2.8 • Public • Published

    react-magnetic-di logo

    react-magnetic-di

    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

    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:

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

    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:

    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:

    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:

    // 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 already injected:

    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 and capabilities 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.

    When you have the same dependency replaced multiple times, there are two behaviours that determine which injectable will "win":

    • the one defined on the closest DiProvider wins. So you can declare more specific replacements by wrapping components with DiProvider or withDi and those will win over same type injectables on other top level DiProviders
    • the injectable defined last in the use array wins. So you can define common injectables but still override each type case by case (eg: <DiProvider use={[...commonDeps, specificInjectable]}>

    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:

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

    FAQ

    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.

    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.

    Install

    npm i react-magnetic-di

    DownloadsWeekly Downloads

    3,253

    Version

    2.2.8

    License

    MIT

    Unpacked Size

    82.8 kB

    Total Files

    48

    Last publish

    Collaborators

    • albertogasparin