Skip to content

nemmtor/injectio

Repository files navigation

Injectio header

Summon React components on demand.

Injectio transforms React components into effect's. Injected components can also return value to the calling code. Perfect for showing modals, forms, and dialogs programmatically.

It makes the component injectable from anywhere - other effects, atoms, event handles, store or any JavaScript code.

Built with Effect. Inspired by Nice Modal.

Setup

Install Injectio using your preferred package manager:

Note

At this point package is not published on npm yet, if you wanna play with injectio, clone the repo.

npm install @injectio/react
yarn add @injectio/react
pnpm add @injectio/react

Add the <Injectio /> component to your app root, typically as one of the last elements:

import { Injectio } from '@injectio/react';

createRoot(root).render(
  <StrictMode>
    <App />
    <Injectio />
  </StrictMode>,
);

Think of <Injectio /> as a React Portal that renders your injected components.

Basic usage

Given any component:

export const SomeDialog = () => {
  return <Dialog open>...</Dialog>;
};

You can make it injectable by wrapping it with the inject function:

import { inject } from '@injectio/react';

const injectSomeDialog = () => inject<...>({
  renderFn: () => <SomeDialog />,
  // ...
});

You've now received ability to render this component programatically by executing returned effect via Effect runtime.

injectSomeDialog.pipe(...)

Understanding Component Lifecycle

Injected component stays mounted for as long as it's scope lives.

Here are some examples:

âś… Correct usage - Wait for the component to complete before closing the scope:

import { Deferred, Effect } from 'effect';

injectSomeDialog().pipe(
  Effect.flatMap(({ deferred }) => Deferred.await(deferred)),
  Effect.scoped,
  Effect.runPromise,
);

âś… Correct usage - Show the component for 5 seconds, then remove it:

import { Duration, Effect } from 'effect';

injectSomeDialog().pipe(
  Effect.tap(Effect.sleep(Duration.seconds(5))),
  Effect.scoped,
  Effect.runPromise,
);

âś… Correct usage - Component stays visible while waiting for parent effect to finish:

Effect.gen(function*(){
  yield* injectSomeDialog();
  const someService = yield* SomeService
  yield* userService.foo
}).pipe(Effect.scoped)

❌ Wrong usage - The scope closes immediately, so the component disappears right away:

import { Effect } from 'effect';

injectSomeDialog().pipe(Effect.scoped, Effect.runPromise);

Returning values to the calling code

The inject function returns an object that includes a deferred property. This deferred value allows your injected components to return data back to the calling code.

The inject function accepts generic types <A, E, P> where:

  • A is the success value type
  • E is the error value type
  • P is for props (covered later)

Completing the Deferred from Outside

You can complete the deferred from anywhere in your app:

export const Example = () => {
  const [deferred, setDeferred] = useState<Deferred.Deferred<undefined>>();

  const injectThing = () =>
    inject<undefined>({
      renderFn: () => <div>Injected thing</div>,
      // ...
    }).pipe(
      Effect.tap(({ deferred }) => Effect.sync(() => setDeferred(deferred))),
      Effect.flatMap(({ deferred }) => Deferred.await(deferred)),
      Effect.tap(() => Effect.sync(() => setDeferred(undefined))),
      Effect.scoped,
      Effect.runPromise,
    );

  return (
    <>
      {!deferred && <Button onClick={injectThing}>Inject thing</Button>}
      {deferred && (
        <Button
          onClick={() =>
            deferred.pipe(Deferred.succeed(undefined), Effect.runPromise)
          }
        >
          Remove thing
        </Button>
      )}
    </>
  );
};

Completing the Deferred from Inside the Component

Most of the time, you'll want to complete the deferred from within the injected component itself. Access the deferred through the renderFn props:

const injectSomething = () =>
  inject<undefined>({
    renderFn: ({ deferred }) => (
      <Something
        onConfirm={() =>
          deferred.pipe(Deferred.succeed(undefined), Effect.runPromise)
        }
      />
    ),
    // ...
  });

Returning Data

In most cases, you'll want to return specific data from your injected component:

const injectProfileForm = () =>
  inject<ProfileFormOutput>({
    renderFn: ({ deferred }) => (
      <ProfileFormDialog
        onConfirm={(formData) =>
          deferred.pipe(Deferred.succeed(formData), Effect.runPromise)
        }
      />
    ),
    // ...
  });

Error Handling

You can also fail the deferred, which gets transformed into Effect's error channel. This works great with Effect's tagged error.

class ProfileFormCancelledError extends Data.TaggedError(
  'ProfileFormCancelledError',
) {}

const injectProfileFormDialog = () =>
  inject<ProfileFormOutput, ProfileFormCancelledError>({
    renderFn: ({ deferred }) => (
      <ProfileFormDialog
        onCancel={() =>
          deferred.pipe(
            Deferred.fail(new ProfileFormCancelledError()),
            Effect.runPromise,
          )()
        }
        onConfirm={(formData) =>
          deferred.pipe(Deferred.succeed(formData), Effect.runPromise)
        }
      />
    ),
    // ...
  });

injectProfileFormDialog().pipe(
  Effect.flatMap(({ deferred }) => Deferred.await(deferred)),
  Effect.catchTags({
    ProfileFormCancelledError: () => injectConfirmationDialog(),
  }),
  Effect.scoped,
  Effect.runPromise,
);

Controlling Props

Injectio provides a way to control props of your injected components. The P generic type defines the type of props you want to control programmatically. Pass these props as initialProps and access them from the renderFn:

type InjectedProps = {
  opened: boolean;
};
const injectSomeDialog = () =>
  inject<undefined, never, InjectedProps>({
    renderFn: ({ props }) => <SomeDialog {...props} />,
    initialProps: {
      opened: true,
    },
  });

Note

Currently, the default type of P is Record<string, never> which means you need to pass an empty object even if you don't want to inject any props. This might change in future versions to make initialProps optional.

You can update these props by calling the updateProps function that is returned from the inject function:

injectSomeDialog().pipe(
  Effect.tap(Effect.sleep(Duration.seconds(2))),
  Effect.tap(({ updateProps }) => {
    updateProps({ opened: false });
  }),
  Effect.flatMap(({ deferred }) => Deferred.await(deferred)),
  Effect.scoped,
  Effect.runPromise,
);

You can access the same updateProps function directly from renderFn:

type InjectedProps = {
  opened: boolean;
};
const injectSomeDialog = () =>
  inject<undefined, never, InjectedProps>({
    renderFn: ({ props, updateProps }) => (
      <SomeDialog {...props} onClick={() => updateProps({ opened: false })} />
    ),
    initialProps: {
      opened: true,
    },
  });

Usage with @effect-atom

Injecting components can be yielded inside other effects which makes it easy to integrate with other effect libraries like @effect-atom:

import { Atom, useAtomValue } from '@effect-atom/atom-react';
import { inject } from '@injectio/react';
import { Deferred, Effect } from 'effect';

class Users extends Effect.Service<Users>()('app/Users', {
  succeed: {
    getAll: Effect.succeed([
      { id: '1', name: 'Alice' },
      { id: '2', name: 'Bob' },
      { id: '3', name: 'Charlie' },
    ]),
  },
}) {}

const runtimeAtom = Atom.runtime(Users.Default);

const selectedUserAtom = runtimeAtom.atom(
  Effect.gen(function* () {
    const users = yield* Users;
    const allUsers = yield* users.getAll;
    const { deferred } = yield* inject<{ id: string; name: string }>({
      renderFn: ({ deferred }) => (
        <SelectUserForm
          users={allUsers}
          onSelect={(user) =>
            deferred.pipe(Deferred.succeed(user), Effect.runPromise)
          }
        />
      ),
      initialProps: {},
    });

    return yield* Deferred.await(deferred);
  }).pipe(Effect.scoped),
);

export const useSelectedUser = () => {
  return useAtomValue(selectedUserAtom);
};

Recipes

Here are some useful examples you can use with Injectio.

Unmount animation

Add a finalizer that updates the prop controlling the open state and waits for the animation to complete:

const SomeDialog = () => {...};

const injectSomeDialog = () =>
  Effect.gen(function* () {
    const result = yield* inject<undefined, never, InjectedProps>({
      renderFn: ({ props }) => {
        return <SomeDialog {...props} />;
      },
      initialProps: {
        open: true,
      },
    });

    yield* Effect.addFinalizer(() => {
      result.updateProps({ open: false });
      return Effect.sleep(Duration.millis(150));
    });

    return result;
  });

Show component while some other effect is executing

You can inject components, change their props and execute different effects concurrently by using Effect's concurrency.

Effect.gen(function* () {
  const injectedComponent = yield* inject(...);
  const apiCallFiber = yield* Effect.fork(someApiCall);
  const apiResult = Fiber.join(apiCallFiber); // not yielded yet to not block code execution

  const slowPath = Effect.void.pipe(
    Effect.tap(() =>
      Effect.sync(() =>
        injectedComponent.updateProps({
          title: "Hold on, usually it doesn't take that long.",
        }),
      ).pipe(Effect.delay(Duration.seconds(5000))),
    ),
    Effect.flatMap(() => apiResult),
  );

  return yield* Effect.raceFirst(apiResult, slowPath);
});

About

Summon React components on demand

Resources

License

Stars

Watchers

Forks

Releases

No releases published