README
@effector/reflect
☄️ Attach effector stores to react components without hooks.
Install
npm install @effector/reflect
# or
yarn add @effector/reflect
Motivation
UI library
Let's agree that we have an internal UI library with an input.
// ./ui.ts
import React, { FC, ChangeEvent, useCallback } from 'react';
type InputProps = {
value: string;
onChange: ChangeEvent<HTMLInputElement>;
};
export const Input: FC<InputProps> = ({ value, onChange }) => {
return <input value={value} onChange={onChange} />;
};
Before
In common case, you need to use useStore
and useEvent
(especially for SSR) to use values and call events from React components.
import React, { FC, ChangeEvent, useCallback } from 'react';
import { createEvent, restore } from 'effector';
import { useStore, useEvent } from 'effector-react';
import { Input } from './ui';
// Model
const changeName = createEvent<string>();
const $name = restore(changeName, '');
// Component
export const Name: FC = () => {
const value = useStore($name);
const nameChanged = useEvent(changeName);
const changed = useCallback(
(event: ChangeEvent<HTMLInputElement>) => nameChanged(event.target.value),
[],
);
return <Input value={value} onChange={changed} />;
};
Now
Now you can create a new component and pass store and event as props without hooks boilerplate.
import { createEvent, restore } from 'effector';
import { reflect } from '@effector/reflect';
import { Input } from './ui';
// Model
const changeName = createEvent<string>();
const $name = restore(changeName, '');
// Component
export const Name = reflect({
view: Input,
bind: { value: $name, onChange: (event) => changeName(event.target.value) },
});
API
Reflect
const Component = reflect({
view: SourceComponent,
bind: Props,
hooks: Hooks,
});
Static method to create a component bound to effector stores and events as stores.
Arguments
view
— A react component that should be used to bind tobind
— Object of effector stores, events or any valuehooks
— Optional object{ mounted, unmounted }
to handle when component is mounted or unmounted.
Returns
- A react component with bound values from stores and events.
Example
// ./user.tsx
import React, { FC, ChangeEvent } from 'react';
import { createEvent, restore } from 'effector';
import { reflect } from '@effector/reflect';
// Base components
type InputProps = {
value: string;
onChange: ChangeEvent<HTMLInputElement>;
placeholder?: string;
};
const Input: FC<InputProps> = ({ value, onChange, placeholder }) => {
return <input value={value} onChange={onChange} placeholder={placeholder} />;
};
// Model
const changeName = createEvent<string>();
const $name = restore(changeName, '');
const changeAge = createEvent<number>();
const $age = restore(changeAge, 0);
const inputChanged = (event: ChangeEvent<HTMLInputElement>) => {
return event.currentTarget.value;
};
// Components
const Name = reflect({
view: Input,
bind: {
value: $name,
onChange: changeName.prepend(inputChanged),
},
});
const Age = reflect({
view: Input,
bind: {
value: $age,
onChange: changeAge.prepend(parseInt).prepend(inputChanged),
},
});
export const User: FC = () => {
return (
<div>
<Name placeholder="Name" />
<Age placeholder="Age" />
</div>
);
};
Variant
const Components = variant({
source: $typeSelector,
bind: Props,
cases: ComponentVariants,
default: DefaultVariant,
hooks: Hooks,
});
Method allows to change component based on value in $typeSelector
. Optional bind
allow to pass props bound to stores or events.
Arguments
source
— Store ofstring
value. Used to select variant of component to render and bound props to.bind
— Optional object of stores, events, and static values that would be bound as props.cases
— Object of components, key will be used to matchdefault
— Optional component, that would be used if no matched incases
hooks
— Optional object{ mounted, unmounted }
to handle when component is mounted or unmounted.
Example
When Field
is rendered it checks for $fieldType
value, selects the appropriate component from cases
and bound props to it.
import React from 'react';
import { createStore, createEvent } from 'effector';
import { variant } from '@effector/reflect';
import { TextInput, Range, DateSelector } from '@org/ui-lib';
const $fieldType = createStore<'date' | 'number' | 'string'>('string');
const valueChanged = createEvent<string>();
const $value = createStore('');
const Field = variant({
source: $fieldType,
bind: { onChange: valueChanged, value: $value },
cases: {
date: DateSelector,
number: Range,
},
default: TextInput,
});
List
const Items: React.FC = list({
view: React.FC<Props>,
source: Store<Item[]>,
bind: {
// regular reflect's bind, for list item view
},
hooks: {
// regular reflect's hooks, for list item view
},
mapItem: {
propName: (item: Item, index: number) => propValue, // maps array store item to View props
},
getKey: (item: Item) => React.Key // optional, will use index by default
});
Method creates component, which renders list of view
components based on items in array in source
store, each item content's will be mapped to View props by mapItem
rules. On changes to source
store, rendered list will be updated too
Arguments
source
— Store ofItem[]
value.view
— A react component, will be used to render list itemsmapItem
— Object{ propName: (Item, index) => propValue }
that defines rules, by which everyItem
will be mapped to props of each rendered list item.bind
— Optional object of stores, events, and static values that will be bound as props to every list item.hooks
— Optional object{ mounted, unmounted }
to handle when any list item component is mounted or unmounted.getKey
- Optional function(item: Item) => React.Key
to set key for every item in the list to help React with effecient rerenders. If not provided, index is used. Seeeffector-react
docs for more details.
Returns
- A react component that renders a list of
view
components based on items of array insource
store. Everyview
component props are bound to array item contents by the rules inmapItem
, and to stores and events inbind
, like with regularreflect
Example
import React from 'react';
import { createStore, createEvent } from 'effector';
import { list } from '@effector/reflect';
const $color = createStore('red');
const $users = createStore([
{id: 1, name: 'Yung'},
{id: 2, name: 'Lean'},
{id: 3, name: 'Kyoto'},
{id: 4, name: 'Sesh'},
]);
const Item = ({ id, name, color }) => {
return (
<li style={{ color }}>
{id} - {name}
</li>
);
};
const Items = list({
view: Item,
source: $users,
bind: {
color: $color
},
mapItem: {
id: (user) => user.id,
name: (user) => user.name
},
getKey: (user) => `${user.id}${user.name}`
});
<List>
<Items />
</List>
Create reflect
Method for creating reflect a view. So you can create a UI kit by views and use a view with a store already.
// ./ui.tsx
import React, { FC, useCallback, ChangeEvent, MouseEvent } from 'react';
import { createReflect } from '@effector/reflect';
// Input
type InputProps = {
value: string;
onChange: ChangeEvent<HTMLInputElement>;
};
const Input: FC<InputProps> = ({ value, onChange }) => {
return <input value={value} onChange={onChange} />;
};
export const reflectInput = createReflect(Input);
// Button
type ButtonProps = {
onClick: MouseEvent<HTMLButtonElement>;
title?: string;
};
const Button: FC<ButtonProps> = ({ onClick, children, title }) => {
return (
<button onClick={onClick} title={title}>
{children}
</button>
);
};
export const reflectButton = createReflect(Button);
// ./user.tsx
import React, { FC } from 'react';
import { createEvent, restore } from 'effector';
import { reflectInput, reflectButton } from './ui';
// Model
const changeName = createEvent<string>();
const $name = restore(changeName, '');
const changeAge = createEvent<number>();
const $age = restore(changeAge, 0);
const submit = createEvent<void>();
// Components
const Name = reflectInput({
value: $name,
onChange: (event) => changeName(event.target.value),
});
const Age = reflectInput({
value: $age,
onChange: (event) => changeAge(parsetInt(event.target.value)),
});
const Submit = reflectButton({
onClick: () => submit(),
});
export const User: FC = () => {
return (
<div>
<Name />
<Age />
<Submit title="Save left">Save left</Submit>
<Submit title="Save right">Save right</Submit>
</div>
);
};
SSR and tests via Fork API
For SSR you will need to replace imports @effector/reflect
-> @effector/reflect/ssr
.
Also for this case you need to use event.prepend(params => params.something)
instead (params) => event(params.something)
in bind
- this way reflect
can detect effector's events and properly bind them to the current scope
// ./ui.tsx
import React, { FC, useCallback, ChangeEvent, MouseEvent } from 'react';
// Input
type InputProps = {
value: string;
onChange: ChangeEvent<HTMLInputElement>;
};
const Input: FC<InputProps> = ({ value, onChange }) => {
return <input value={value} onChange={onChange} />;
};
// ./app.tsx
import React, { FC } from 'react';
import { createEvent, restore, Fork, createDomain } from 'effector';
import { reflect } from '@effector/reflect/ssr';
import { Provider } from 'effector-react/ssr';
import { Input } from './ui';
// Model
export const app = createDomain();
export const changeName = app.createEvent<string>();
const $name = restore(changeName, '');
// Component
const Name = reflect({
view: Input,
bind: {
value: $name,
onChange: changeName.prepend((event) => event.target.value),
},
});
export const App: FC<{ data: Fork }> = ({ data }) => {
return (
<Provider value={data}>
<Name />
</Provider>
);
};
// ./server.ts
import { fork, serialize, allSettled } from 'effector';
import { App, app, changeName } from './app';
const render = async () => {
const scope = fork(app);
await allSettled(changeName, { scope, params: 'Bob' });
const data = serialize(scope);
const content = renderToString(<App data={scope} />);
return `
<body>
${content}
<script>
window.__initialState__ = ${JSON.stringify(data)};
</script>
</body>
`;
};
Also, to use reflected components with SSR and effector or testing via effector's Fork API you will need to mark @effector/reflect
and @effector/reflect/ssr
as a fabric import via effector/babel-plugin
// in your .babelrc
{
"plugins": [
[
"effector/babel-plugin",
{
"factories": ["@effector/reflect", "@effector/reflect/ssr"]
}
]
]
}
Hooks
Hooks is an object passed to variant()
or match()
with properties mounted
and unmounted
all optional.
Example
import { createStore, createEvent } from 'effector';
import { reflect, variant } from '@effector/reflect';
import { TextInput, Range } from '@org/my-ui';
const $type = createStore<'text' | 'range'>('text');
const $value = createStore('');
const valueChange = createEvent<string>();
const rangeMounted = createEvent();
const fieldMounted = createEvent();
const RangePrimary = reflect({
view: Range,
bind: { style: 'primary' },
hooks: { mounted: rangeMounted },
});
const Field = variant({
source: $type,
bind: { value: $value, onChange: valueChange },
cases: {
text: TextInput,
range: RangePrimary,
},
hooks: { mounted: fieldMounted },
});
When Field
is mounted, fieldMounted
and rangeMounted
would be called.
Roadmap
- [] Auto moving test from ./src to ./dist-test
Release process
- Check out the draft release.
- All PRs should have correct labels and useful titles. You can review available labels here.
- Update labels for PRs and titles, next manually run the release drafter action to regenerate the draft release.
- Review the new version and press "Publish"
- If required check "Create discussion for this release"