Skip to content

Dependency injection and replacement for React components and hooks

License

Notifications You must be signed in to change notification settings

sean-cullinan/react-magnetic-di

 
 

Repository files navigation

magnetic-di logo

magnetic-di

A new take for dependency injection / dependency replacement 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)
  • Promotes type safety for mocks
  • Works with any kind of value (funcitons, objects, strings) and in all closures / React components
  • Replaces dependencies at any depth of the call chain / React tree
  • Allows selective injection
  • Enforces separation of concerns, keeps your component API clean
  • Proper ES Modules support, as it does not mess up with modules/require or React internals

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. magnetic-di takes inspiration from decorators, and with a touch of Babel magic allows you to optionally override imported/exported values in your code so you can swap implementations only when needed.

Usage

npm i react-magnetic-di
# or
yarn add react-magnetic-di

Adding babel plugin

Edit your Babel config file (.babelrc / babel.config.js / ...) and add:

  // ... other stuff like presets
  plugins: [
    // ... other plugins
    'react-magnetic-di/babel-plugin',
  ],

This is where the magic happens: we safely rewrite the code to prepend di(...) in every function scope, so that the dependency value can be swapped. We recommend to only add the plugin in development/test enviroments to avoid useless const assignment in production. You can either do that via multiple babel environment configs or by using enabledEnvs option.

Using dependency replacement

Once babel is configured, in your tests you can create type safe replacements via injectable and then use runWithDi , which will setup and clear the replacements for you after function execution is terminated. Such util also handles async code, but might require you to wrap the entire test to work effectively with scheduled code paths, or event driven implementations.

Assuming your source is:

import { fetchApi } from './fetch';

export async function myApiFetcher() {
  const { data } = await fetchApi();
  return data;
}

Then in the test you can write:

import { injectable, runWithDi } from 'react-magnetic-di';
import { fetchApi } from './fetch';
import { myApiFetcher } from '.';

it('should call the API', async () => {
  // injectable() needs the original implementation as first argument
  // and the replacement implementation as second
  const fetchApiDi = injectable(fetchApi, jest.fn().mockResolvedValue('mock'));

  const result = await runWithDi(() => myApiFetcher(), [fetchApiDi]);

  expect(fetchApiDi).toHaveBeenCalled();
  expect(result).toEqual('mock');
});

Usin dependency replacement in React tests and storybooks

For React, we provide a specific DiProvider to enable replacements across the entire tree. 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:

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

Here 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]}>

Other replacement patterns

Allowing globals replacement

Currently the library does not enable automatic replacement of globals. To do that, you need to manually "tag" a global for replacement with di(myGlobal) in the function scope. For instance:

import { di } from 'react-magnetic-di';

export async function myApiFetcher() {
  // explicitly allow fetch global to be injected
  di(fetch);
  const { data } = await fetch();
  return data;
}

Alternatively, you can create a "getter" so that the library will pick it up:

export const fetchApi = (...args) => fetch(...args);

export async function myApiFetcher() {
  // now injection will automatically work
  const { data } = await fetchApi();
  return data;
}

Ignoring a function scope

Other times, there might be places in code where auto injection is problematic and might cause infine loops. It might be the case if you are creating an injectable that then imports the replacement source itself.

For those scenarios, you can add a comment at the top of the function scope to tell the Babel plugin to skip that scope:

import { fetchApi } from './fetch';

export async function myApiFetcher() {
  // di-ignore
  const { data } = await fetchApi();
  return data;
}

Tracking unused injectables

By default magnetic-di does not complain if an injectable is not used or if a dependency has not being replaced. In large codebases however, that might led to issues with stale, unused injectables or with lack of knowledge in what could be replaced. To ease introspection, the library provides a stats API that returns unused injectables.

  • stats.unused() returns an array of entries { get(), error() } for all injectables that have not been used since stats.reset() has been called

This is an example of stats guard implementation using the returned error() helper:

import { stats } from 'react-magnetic-di';

beforeEach(() => {
  // it's important to reset the stats after each test
  stats.reset();
});
afterEach(() => {
  stats.unused().forEach((entry) => {
    // throw an error pointing at the test with the unused injectable
    throw entry.error();
  });
});

Configuration Options

Babel plugin options

The plugin provides a couple of options to explicitly disable auto injection for certain paths, and overall enable/disable replacements on specific environments:

  // In your .babelrc / babel.config.js
  // ... other stuff like presets
  plugins: [
    // ... other plugins
    ['react-magnetic-di/babel-plugin', {
      // List of paths to ignore for auto injection. Recommended for mocks/tests
      exclude: ['mocks', /test\.tsx?/],
      // List of Babel or Node environment names where the plugin should be enabled
      enabledEnvs: ['development', 'test'],

    }],
  ],

injectables options

When creating injectables you can provide a configuration object to customise some of its behaviour. • displayName: provide a custom name to make debugging easier:

const fetchApiDi = injectable(fetchApi, jest.fn(), { displayName: 'fetchApi' });

target: allows a replacement to only apply to specific function(s):

const fetchApiDi = injectable(fetchApi, jest.fn(), { target: fetchProjects });

track: skip reporting it in stats.unused() (handy if you provide default injectables across tests):

const fetchApiDi = injectable(fetchApi, jest.fn(), { track: false });

ESLint plugin and rules

In order to enforce better practices, this package exports some ESLint rules:

rule description
order enforces di(...) to be the top of the block, to reduce chances of partial replacements
no-duplicate prohibits marking the same dependency as injectable more than once in the same scope
no-extraneous enforces dependencies to be consumed in the scope, to prevent unused variables
no-restricted-injectable prohibits certains values from being injected: paths: [{ name: string, importNames?: string[], message?: string }]
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

  • DiProvider does not support dynamic use and target props (changes are ignored)
  • 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.
  • Injecting primitive values (strings, booleans, numbers, ...) can be unreliable as we only have the actual value as reference, and so the library might not exactly know what to replace. In cases where multiple values might be replaced, a warning will be logged and we recommend you declare an inject a getter instead of the value itself.
  • Targeting only works on named functions/classes, so it won't work on anonymous scopes (eg export default () => { ... } or memo(() => { ... }))

FAQ

Cannot seem to make injectable work

A way to check if some dependency has been tagged for injection is to use the debug util, as it will print all values that are available for injection:

import { debug } from 'react-magnetic-di';
// ...
console.log(debug(myApiFetcher));
// It will print ['fetchApi']

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.

About

Dependency injection and replacement for React components and hooks

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • JavaScript 95.4%
  • TypeScript 4.6%