Skip to content

Latest commit

 

History

History
560 lines (419 loc) · 16.6 KB

README.md

File metadata and controls

560 lines (419 loc) · 16.6 KB

react-service-container

Build Status

react-service-container is a library which helps provide services to your components and hooks in an easy, clean, and testable manner.

Simply define a service

// greeter.js

export default class Greeter {
  greet() {
    return "👋 Hello there!";
  }
}

provide it within a ServiceContainer

// App.js
import React from "react";
import { ServiceContainer } from "react-service-container";
import Greeter from "./greeter";
import Greeting from "./Greeting";

export default function App() {
  return (
    <ServiceContainer providers={[Greeter]}>
      <Greeting />
    </ServiceContainer>
  );
}

and use it within your components:

// Greeting.js
import React from "react";
import { useService } from "react-service-container";
import Greeter from "./greeter";

export default function Greeting() {
  const greeter = useService(Greeter);
  return <h1>{greeter.greet()}</h1>;
}

Testing your components is a breeze:

import React from "react";
import { render } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import Greeter from "./greeter";
import Greeting from "./greeting";

// Greeting.spec.js
test("renders a greeting", () => {
  const fakeGreet = jest.fn();
  fakeGreet.mockReturnValue("expected greeting");

  const { asFragment } = render(
    <ServiceContainer
      providers={[{ provide: Greeter, useValue: { greet: fakeGreet } }]}
    >
      <Greeting />
    </ServiceContainer>
  );

  expect(asFragment()).toHaveTextContent("expected greeting");
});

View a working example of the above code

The library is based off of the Service Locator pattern, and the API is inspired by apollo-client.

Features

  • Dead simple to use. No annotations, reflect-metadata, etc. needed
  • Idiomatically React. First-class support for hooks and components. Includes react-style descriptive error messages.
  • Supports hierarchal containers for lazy loading and code splitting.
  • First-class TypeScript support
  • Fully tested with 100% code coverage

Unlike similar libraries, this is not a dependency injection library. The sole purpose of this library is to provide components with services.

Motivation

When developing React applications, I find that using Context together with Custom Hooks provides a really clean and powerful way to inject services into components. For example, if I have a Greeter service, such as I used in the example above, I could write something like this:

// greeter.js
import { createContext, useContext } from "react";

export default class Greeter {
  greet() {
    // ...
  }
}

export const GreeterContext = createContext(null);

export function useGreeter() {
  const instance = useContext(Greeter);
  if (!instance) {
    throw new Error(`[useGreeter] Greeter was never provided`);
  }
  return instance;
}

Then, in my application code I can provide Greeter at runtime, again as shown above:

// App.js
import React from "react";
import Greeter, { GreeterContext } from "./greeter";
import Greeting from "./Greeting";

const greeter = new Greeter();
export default function App() {
  return (
    <GreeterContext.Provider value={greeter}>
      <Greeting />
    </GreeterContext.Provider>
  );
}

NOTE: In a real app, Greeting may be nested somewhere deep down in the component tree, while in this example it may look like it would just be easier to pass it as a prop, in a real application you'd have to pass it down an entire component tree, making this method more appealing (to me at least).

Finally, I could use my custom hook in my Greeting component:

// Greeting.js
import React from "react";
import { useGreeter } from "./greeter";

export default function Greeter() {
  const greeter = useGreeter();
  return <h1>{greeter.greet()}</h1>;
}

This not only makes it super easy for components to consume services, but once I started using it, I realized I also preferred providing service mocks in tests explicitly vs. using Jest's module mocking. I found that explicitly specifying the services my components rely upon made me less likely to mock out implementation details and ensure I drew clear boundaries around separation of concern.

I also prefer to encapsulate services as classes (call me old-school I guess?), and found Jest's ES6 class mocking to be a bit difficult. This of course is just my personal opinion 😅

However, once I started doing this with multiple services, e.g. FooService, BarService, BazService, I started to get into this slippery slope where not only was I writing a ton of boilerplate code for every service, but my code started looking more diagonal vs. vertical when declaring services.

import FooService, { FooContext } from "./fooService";
import BarService, { BarContext } from "./barService";
import BazService, { BazContext } from "./bazService";

const foo = new Foo();
const bar = new Bar();
const baz = new Baz();
export default function App() {
  <FooContext.Provider value={foo}>
    <BarContext.Provider value={bar}>
      <BazContext.Provider value={baz}>{/* ... */}</BazContext.Provider>
    </BarContext.Provider>
  </FooContext.Provider>;
}

I wanted a way to generalize the concept of providing services via contexts and hooks in an easy and intuitive manner, and took inspiration from Angular's dependency injection system to do so (but without the complexity that true DI comes with). This turned out to work well for my use cases, and hence react-service-container was born.

With react-service-container, the above becomes:

import FooService from "./fooService";
import BarService from "./barService";
import BazService from "./bazService";
import { ServiceContainer } from "react-service-container";

export default function App() {
  <ServiceContainer providers={[FooService, BarService, BazService]}>
    {/* ^_^ */}
  </ServiceContainer>;
}

Not to mention no more Context / hook definition boilerplate in your services.

MUCH BETTA!

Installation

npm i -S react-service-container

UMD Builds

UMD builds can be found in the npm package's umd/ folder, containing both development (react-service-container.js) and production (react-service-container.min.js) builds. Source maps are included in the folder.

If you'd like to include react-service-container using a <script> tag, you can use unpkg to do so.

<script src="https://unpkg.com/react-service-container/umd/react-service-container.min.js"></script>

Usage

Providing services

In order to use react-service-container, you must create a top-level ServiceContainer component, and pass it a list of providers via its providers prop that tell react-service-container what services are available, and how to constructor them.

import Greeter from "./greeter";
import ApiClient from "./apiClient";

ReactDOM.render(
  <ServiceContainer
    providers={[
      Greeter,
      {
        provide: ApiClient,
        useValue: new ApiClient({ endpoint: "http://example.com/api" }),
      },
    ]}
  >
    <PageComponent />
  </ServiceContainer>
);

You can then use the useService() hook within your components in order to make use of a service.

NOTE: Each service is only instantiated once, the first time useService() is called.

The Provider API

If you're familiar with Angular's DI providers, you're familiar with ours. The API is pretty much the same.

Providers come in two forms:

  • An object with a provide key whose value is the service token you wish to use to represent the service provided, and an additional key the options of which are described below.
  • A Function object you pass as a shorthand for {provide: Function, useClass: Function}

The providers you can use are listed below in the code example:

class MyService {}

const providers = [
  MyService, // Class shorthand
  { provide: MyService, useClass: MyService }, // Equivalent to the above
  { provide: MyService, useValue: new MyService() }, // Provide a concrete value to be used
  { provide: MyService, useFactory: () => new MyService() }, // Provide a factory function, useful for configuring the service
];

You can also alias dependencies via useExisting

class NewService {}

const providers = [MyService, { provide: NewService, useExisting: MyService }];

This is useful for gradually deprecating APIs.

See the tests in this repo for example of using each.

Using Hierarchal Containers

react-service-container fully supports hierarchal <ServiceContainer> components. When <ServiceContainer> components are nested within one another, useService() calls act exactly like variable lookups: starting from the inner-most to the outer-most service container, the first service token found will be used.

Using hierarchal service containers not only lets you keep different parts of your codebase cleanly separated, but it allows for lazy loading of services at the time in which you need it.

Say you have an app which shows a list of TODOs, as well as a settings page. You lazily load the page since users tend to navigate to either one page or the other, most likely TODOs. Chances are that the services for the TODOs page might be different than those used for the settings page. However, some, such as getting information about the current user, might be shared across the entire application. Using hierarchal service containers allows the top-level application to contain shared modules, while lazily loaded feature modules can configure their services at load time.

// src/todos/index.js

import React from "react";
import { ServiceContainer } from "react-service-container";
import TodosService from "./todosService";

export default function Todos() {
  return (
    <ServiceContainer providers={[TodosService]}>
      {/* Render TODOs */}
    </ServiceContainer>
  );
}
// src/settings/index.js

import React from "react";
import { ServiceContainer } from "react-service-container";
import SettingsService from "./settingsService";

export default function Settings() {
  return (
    <ServiceContainer providers={[SettingsService]}>
      {/* Render settings */}
    </ServiceContainer>
  );
}
// src/App.js

import React, { Suspense, lazy } from "react";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import { ServiceContainer } from "react-service-container";

// Common dependency
import UserService from "./userService";
// Loading indicator
import Loading from "./components/Loading";

// Lazily loaded components
const Todos = lazy(() => import("./todos"));
const Settings = lazy(() => import("./settings"));

export default function App() {
  return (
    <ServiceContainer providers={[UserService]}>
      <Router>
        <Suspense fallback={<Loading />}>
          <Switch>
            <Route path="/app" component={Todos} />
            <Route path="/settings" component={Settings} />
            <Route exact path="/">
              <Redirect to="/app" />
            </Route>
          </Switch>
        </Suspense>
      </Router>
    </ServiceContainer>
  );
}

Now any components/hooks under Todos or Settings can call useService(UserService), but only components/hooks under Todos can call useService(TodosService) and same for Settings and useService(SettingsService);

Wrapping non-class dependencies

Provide is simple. provide can be any JS object. Theoretically {provide: 'hello!', useValue: {...}} would work. If you want to inject something that's not a class, try using Symbols.

/** config.js */

export const config = {
  ...,
};

// NOTE: A string would work fine as well, if you wanted to be simpler. My preference
// is to go for Symbols since they're completely unambiguous.
export const CONFIG = Symbol.for("config");

/** App.js */

import {CONFIG, config} from "./config";

function App() {
  return <ServiceContainer providers={[{provide: CONFIG, useValue: config}]}>{...}</ServiceContainer>
}

Usage within class components

Service containers can be easily used without hooks in class components as well. Simply set the component's contextType property to ServiceContainerContext and use this.context.get() inside the render method or anywhere else that's needed.

/* Greeting.js */

import React from "react";
import { ServiceContainerContext } from "react-service-container";
import Greeter from "./greeter";

class MyComponent extends React.Component {
  static contextType = ServiceContainerContext;

  render() {
    const greeter = this.context.get(Greeter);
    return <p>{greeter.greet()}</p>;
  }
}

Usage with TypeScript

react-service-container is written in TypeScript, and comes with first-class support for it. When using Function objects, such as class constructors, with useService(), it is properly typed as an instance of that constructor.

Let's take the example from the introduction and rewrite it in TypeScript.

// greeter.ts

export default class Greeter {
  greet(): string {
    return "👋 Hello there!";
  }
}
// App.tsx
import React from "react";
import { ServiceContainer } from "react-service-container";
import Greeter from "./greeter";
import Greeting from "./Greeting";

export default function App() {
  return (
    <ServiceContainer providers={[Greeter]}>
      <Greeting />
    </ServiceContainer>
  );
}
// Greeting.tsx
import React from "react";
import { useService } from "react-service-container";
import Greeter from "./greeter";

export default function Greeting() {
  const greeter = useService(Greeter);
  return <h1>{greeter.greet()}</h1>;
}

In the above component, greeter is correctly typed to Greeter, ensuring type correctness and consistency.

Non-class services

What about the config example earlier?

/** config.ts */

export interface Config {/* ... */}

export const config: Config = {
  ...,
};

export const CONFIG = Symbol.for("config");

/** App.tsx */

import {CONFIG, config} from "./config";

function App() {
  return <ServiceContainer providers={[{provide: CONFIG, useValue: config}]}>{...}</ServiceContainer>
}

Here's how we might use that in a component:

// Component.tsx
import { useService } from "react-service-container";
import { CONFIG } from "./config";

export default function Component() {
  const config = useService(CONFIG);
  // render component
}

Here, config is typed as any. This is because based on the given Symbol, TypeScript does not statically know what type the value associated with that symbol is; the symbol could represent any type.

However, because it's cast to any, we can easily typecast the result

import { useService } from "react-service-container";
import { CONFIG, Config } from "./config";

export default function Component() {
  const config = useService(CONFIG);
  // render component
}
const config = useService(CONFIG) as Config;

This is still less than ideal, since the TypeCasting is ugly and can be repetitive. Here is my preferred approach:

// config.ts
import {useService} from 'react-service-container';

export interface Config {/* ... */}

export const config: Config = {
  ...,
};

const configToken = Symbol.for("config");

export const CONFIG_PROVIDER = {
  provide: configToken,
  useValue: config
};

export const useConfig = () => useService(configToken) as Config;

By providing a custom useConfig hook, and defining the provider within the component, it dramatically reduces the error surface and repetition of doing manual type-checking, and allows you to abstract away the service token for the config itself.

// App.tsx
import {ServiceContainer} from 'react-service-container';
import {CONFIG_PROVIDER} from './config';

function App() {
  return <ServiceContainer providers={[CONFIG_PROVIDER]}>{...}</ServiceContainer>
}

// Component.tsx

import {useConfig} from './config';

export default function Component() {
  const config = useConfig();
  // render component using config, which is now correctly typed.
}

License

MIT