npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

hardwired-react

v1.3.0

Published

Dependency injection container for React.

Downloads

245

Readme

Hardwired React

Integration for Hardwired and React.

Table of Contents

Motivation

Dependency injection pattern is one of the fundamental techniques for writing modular, loosely coupled, and testable code. The pattern is usually associated with object-oriented programming, where the construction of dependencies' graph is most often delegated to the Inversion of Control Container, but dependency injection is also present in functional programming in the form of partial application/currying or the reader monad.

Relevant resources:

Dependency injection is also relevant in React applications. React already provides a mechanism for dependency injection in the form of context. This library aims to provide an opinionated semantics for defining and injecting dependencies to React components.

Limitations

React context supports basic reactivity/change detection for the state stored within the context, but it incurs performance penalties in the case of frequent updates. Additionally, the container implementation used by Hardwired internally relies on mutable state, which is not compatible with shallow comparisons that React uses for change detection. Due to these limitations, hardwired-react does not provide observability features for objects created by the container. However, observability can be easily enabled by using MobX or other libraries that offer similar functionality.

Installation

The following examples will use mobx for observability.

Depending on your package manager run:

npm install hardwired hardwired-react mobx mobx-react
yarn add hardwired hardwired-react mobx mobx-react
bun add hardwired hardwired-react mobx mobx-react

Getting started

  1. Create the model layer.

These examples use OOP, but Hardwired also provides support for functional programming style.

import { makeAutoObservable } from 'mobx';
import { cls, value } from 'hardwired';

const initialValue = value(0);

export class CounterStore {
  static class = cls.singleton(this, initialValue);

  constructor(public value: number) {
    makeAutoObservable(this);
  }
}

export class CounterActions {
  static class = cls.singleton(this, [CounterStore.instance]);

  constructor(private store: CounterStore) {
    makeAutoObservable(this);
  }

  increment = () => {
    this.store.value += 1;
  };

  decrement = () => {
    this.store.value -= 1;
  };
}

For purpose of this example we use singleton lifetime. For the detailed explanation of life times, please refer to Hardwired docs documentation

  1. Create components

     import { use } from './use.js';
     import { observer } from 'mobx-react';
    
     export const Counter = observer(() => {
       const state = use(CounterStore.class);
    
       return (
         <h2>
           Current value: <span data-testid={'counter-value'}>{state.value}</span>
         </h2>
       );
     });
    
     export const CounterButtons = observer(() => {
       const actions = use(CounterActions.class);
    
       return (
         <>
           <button onClick={actions.increment}>Increment</button>
           <button onClick={actions.decrement}>Decrement</button>
         </>
       );
     });
  2. Wrap application with ContainerProvider

    import { FC } from 'react';
    import { ContainerProvider } from 'hardwired-react';
    
    export const App: FC = () => {
      return (
        <ContainerProvider>
          <Counter />
          <CounterButtons />
        </ContainerProvider>
      );
    };

Testing

State

By using plain javascript classes for CounterStore and CounterActions, they are not coupled to React and can be tested without using any helpers (like render from @testing-library/react) which are required for rendering a component. This separation wouldn't be possible if we would implement counter as a hook, that stores state using useState.

import { all, container } from 'hardwired';

describe('CounterAction', () => {
  describe('.increment()', () => {
    // manually creating instances
    it('increments counter state by 1', () => {
      const counterStore = new CounterStore(0);
      const counterStoreActions = new CounterActions(counterStore);
      counterStoreActions.increment();
      expect(counterStore.value).toEqual(1);
    });

    // delegating instances construction to the container
    it('increments counter state by 1', () => {
      const [counterStore, counterStoreActions] = all(CounterStore.instance, CounterActions.class);

      counterStoreActions.increment();
      expect(counterStore.value).toEqual(1);
    });

    // delegating instances construction to container and
    // overriding initial value for the counter store
    it('increments counter state by 1', () => {
      const cnt = container.new(container => {
        container.bind(initialValue).toValue(10);
      });
      const [counterStore, counterStoreActions] = cnt.all(CounterStore.instance, CounterActions.class);

      counterStoreActions.increment();
      expect(counterStore.value).toEqual(11);
    });
  });
});

Components

React components can be tested using both unit and integration-oriented approaches. Without using dependency injection, we are somewhat forced to the latter.

Integration tests focus on testing the component's real, user-facing behavior. They are not burdened with testing implementation details, so in theory, they shouldn't be as fragile as unit tests. Unfortunately, in the case of complex components, depending solely on integration tests can be costly because they often require a complex setup for every test case. In this section, I will present a more unit-test-oriented approach. (In a real-world application, one should probably find a good balance between both approaches).

In unit tests for CounterActions, we want to check if the correct action methods are called on corresponding button clicks. We are not interested in side effects that are triggered by these methods because this behavior was already tested in the previous suite.

// CounterActions.test.tsx
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Container, apply, container } from 'hardwired';
import { ContainerProvider } from 'hardwired-react';

describe('CounterButtons', () => {
  function setup() {

    const cnt = container(container => {
      container.bind(CounterActions.class).configure((_, counterActions) => {
        vi.spyOn(counterActions, 'increment');
        vi.spyOn(counterActions, 'decrement');
      })
    })

    const result = render(
      <ContainerProvider container={cnt}>
        <CounterButtons />
      </ContainerProvider>,
    );

    return {
      clickIncrementButton: () => {
        const incrementBtn = result.getByRole('button', { name: /increment/i });
        userEvent.click(incrementBtn);
      },
      clickDecrementButton: () => {
        const decrementBtn = result.getByRole('button', { name: /decrement/i });
        userEvent.click(decrementBtn);
      },
      counterActions: cnt.use(CounterActions.class),
    };
  }

  it(`calls correct method on "increment" button click`, async () => {
    const { counterActions, clickIncrementButton } = setup();
    clickIncrementButton();
    expect(counterActions.increment).toBeCalledTimes(1);
  });

  it(`calls correct method on "decrement" button click`, async () => {
    const { counterActions, clickDecrementButton } = setup();
    clickDecrementButton();
    expect(counterActions.decrement).toBeCalledTimes(1);
  });
});

For the Counter unit tests we just want to make sure that correct counter value was rendered. Optionally we can also check if the component re-renders on value change.

// CounterActions.test.tsx
import {render, fireEvent, waitFor, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {Container, container} from 'hardwired';
import {ContainerProvider} from 'hardwired-react';
import {runInAction} from 'mobx';

describe('CounterButtons', () => {
  function setup(startCountValue: number) {
    const cnt = container.new(c => {
      c.bindLocal(initialValue).toValue(startCountValue)
    })

    const result = render(
      <ContainerProvider container={cnt}>
        <Counter/>
      </ContainerProvider>,
    );

    return {
      getRenderedValue: () => {
        return result.getByTestId('counter-value').text;
      },
      setCounterValue: (newValue: number) => {
        const store = cnt.use(CounterStore.class);
        runInAction(() => {
          store.value = newValue;
        });
      },
    };
  }

  it(`renders correct value`, async () => {
    const {getRenderedValue} = setup(1);
    expect(getRenderedValue()).toEqual('1');
  });

  it(`re-renders on counter value change`, async () => {
    const {getRenderedValue, setCounterValue} = setup(1);
    setCounterValue(200);
    expect(getRenderedValue()).toEqual('200');
  });
});

Unbound Dependencies

There are cases where some objects injected into the component need to be parameterized (e.g., using props). For such scenarios, Hardwired provides unbound definitions, for which the values can be provided at runtime. The following example enables adding multiple labeled instances of counters from the getting-started section.

// counter.ts
import { makeAutoObservable } from 'mobx';
import { external, scoped, cls } from 'hardwired';

const initialValue = value(0);
const label = unbound<string>();

class CounterStore {
  static class = cls.scoped(this, [initialValue, label]);

  constructor(
    public value: number,
    public label: string,
  ) {
    makeAutoObservable(this);
  }
}

class CounterActions {
  static class = cls.scoped(this, [CounterStore.instance]);

  constructor(private store: CounterStore) {
    makeAutoObservable(this);
  }

  increment = () => {
    this.store.value += 1;
  };

  decrement = () => {
    this.store.value -= 1;
  };
}

Notice that the lifetime for counter store and counter actions was changed from singleton to scoped. Additionally, the counter store takes label parameter that will be passed at runtime.

import { use, ContainerProvider, ContainerScope } from 'hardwired-react';
import { observer } from 'mobx-react';

export const Counter = observer(() => {
  const store = use(CounterStore.class);
  return (
    <h2>
      Current value: <span data-testid={'counter-value'}>{store.value}</span>
    </h2>
  );
});

export const CounterLabel = observer(() => {
  const store = use(CounterStore.class);
  return <h2>{store.label}</h2>;
});

export const CounterButtons = observer(() => {
  const actions = use(CounterActions.class);

  return (
    <>
      <button onClick={actions.increment}>Increment</button>
      <button onClick={actions.decrement}>Decrement</button>
    </>
  );
});

export const LabeledCounter = observer(() => {
  return (
    <div>
      <CounterLabel />
      <Counter />
      <CounterButtons />
    </div>
  );
});

export const App = () => {
  const scope1 = useScopeConfig(scope => {
    scope.bind(label).toValue('first counter');
  })

  const scope2 = useScopeConfig(scope => {
    scope.bind(label).toValue('second counter');
    scope.bind(initialValue).toValue(100);
  })

  return (
    <ContainerProvider>
      <ContainerScope config={scope1}>
        <LabeledCounter />
      </ContainerScope>

      <ContainerScope config={scope2}>
        <LabeledCounter />
      </ContainerScope>
    </ContainerProvider>
  );
};

Considerations

Using an IoC (Inversion of Control) container for such a simple scenario might seem like overkill, especially when the component structure is straightforward. For instance, one could simply pass a label as a prop to <LabeledCounter/>, which then forwards it to <CounterLabel/>. This simple approach allows for rendering two instances of the component with different labels.

However, the example demonstrates a key advantage of using an IoC container: it eliminates the need for parent components to be aware of the specific dependencies required by deeper or more distant components in the tree (and they don't need to prop-drill them). This is particularly relevant for container components, which are typically more complex than dummy/presentational components because they manage all the dependencies needed by their child components. By offloading this complexity to an IoC container, we simplify top-level components, allowing them to focus solely on composing their children without getting involved in the intricacies of their implementations. This approach aligns with treating React components primarily as a view layer, akin to the MVC pattern, and facilitates the separation of business logic into plain objects (or functions using functional style), simplifying object creation and encapsulation.

Unfortunately, this method has its drawbacks. Retrieving dependencies with use introduces an additional layer of indirection compared to direct prop passing. The dependencies managed by use form a hierarchy (a directed acyclic graph) that does not usually align 1:1 with the component hierarchy. This flexibility can be advantageous, particularly when sharing data across many components, but it can also obscure the flow of data and dependencies through the component structure.

Furthermore, using use ties components more closely to the Hardwired library, which can be restrictive. Where possible, using simpler dummy/presentational components as the leaves nodes in the component tree is preferable.

The ease of injecting dependencies can also lead to excessive coupling between components and instances retrieved from the container. This can potentially make the code harder to understand. This complexity can be mitigated by enforcing strict controls over the mutability of injected objects. Typically, injecting read-only objects into multiple components does not lead to issues. However, uncontrolled mutability with side effects that are accessible to multiple consumers can introduce significant unpredictability and complexity.

Mapping Definition Life Time to the React Components Rendering

Note: the following examples don't use mobx, as they don't mutate any data, so there is no need to rerender.

Scoped Lifetime

The values are memoized in the closest <ContainerScope> or ContainerProvider. Both mentioned components internally hold their own private state for scope.

import { scoped } from 'hardwired';
import { use, ContainerProvider, ContainerScope } from 'hardwired-react';


const value = scoped.fn(use => Math.random());

const Presenter = () => {
  const _value = use(value);
  return <span>{_value}</span>;
};

const App = () => {
  return (
    <ContainerProvider>
      <Presenter />

      <ContainerScope>
        <Presenter />
      </ContainerScope>
      <ContainerScope>
        <Presenter />
      </ContainerScope>
    </ContainerProvider>
  );
};

In this example each <Presenter/> component will display different value, because each one is wrapped with a different scope.

Singleton

Singleton instances created by use become globally cached and are available for all components wrapped with a common ContainerProvider.

import { singleton } from 'hardwired';
import { use, ContainerProvider, ContainerScope } from 'hardwired-react';

const value = singleton.fn(use => Math.random());

const Parent = () => {
  const _value = use(value);

  return (
    <ContainerScope>
      <Child />
    </ContainerScope>
  );
};

const Child = () => {
  const _value = use(value);

  // value is equal to the value from the Parent
  return <span>{_value}</span>;
};

const App = () => {
  return <ContainerProvider>
    <Parent>
  </ContainerProvider>

}

In this example both <Parent> and <Child> components will get the same value by calling use(value) as they are wrapped by common <ContainerProvider>

Transient

  • transient instances are created on each component rerender
import { transient } from 'hardwired';
import { use } from 'hardwired-react';

const counter = singleton.fn(() => {
  return { value: 0 }
})

const countValue = transient.fn(use => {
  const count = use(counter);
  count.value += 1;
});

const Parent = () => {
  const value = use(countValue); // returns a value incremented by 1 on every render

  return (
    <h1>
      Component rendered <span>{value}</span> times
    </h1>
  );
};

Functional API

If you prefer a more functional programming style, the previous counter example can be implemented as follows:

import { fn, value } from 'hardwired';
import { use } from 'hardwired-react';
import { action, observable } from 'mobx';
import { observer } from 'mobx-react';

// model
const initialValue = value(0);

const counterStore = fn.singleton(use => {
  return observable({ value: use(initialValue) });
});

const incrementAction = fn.singleton(use => {
  const store = use(counterStore);

  return action(() => (store.value += 1));
});

const decrementAction = fn.singleton(use => {
  const store = use(counterStore);

  return action(() => (store.value -= 1));
});

// view
export const Counter = observer(() => {
  const state = use(counterStore);

  return (
    <h2>
      Current value: <span data-testid={'counter-value'}>{state.value}</span>
    </h2>
  );
});

export const CounterButtons = observer(() => {
  const increment = use(incrementAction);
  const decrement = use(decrementAction);

  return (
    <>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </>
  );
});