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.
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.
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(...)
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);
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 typeE
is the error value typeP
is for props (covered later)
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>
)}
</>
);
};
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)
}
/>
),
// ...
});
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)
}
/>
),
// ...
});
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,
);
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,
},
});
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);
};
Here are some useful examples you can use with Injectio.
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;
});
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);
});