☄️ Attach effector stores to react components without hooks.
npm install @effector/reflect
# or
yarn add @effector/reflect
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} />;
};
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 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) },
});
const Component = reflect({
view: SourceComponent,
bind: Props,
hooks: Hooks,
});
Static method to create a component bound to effector stores and events as stores.
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.
- A react component with bound values from stores and events.
// ./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>
);
};
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.
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.
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,
});
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
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.
- 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
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>
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>
);
};
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 is an object passed to variant()
or match()
with properties mounted
and unmounted
all optional.
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.
- [] Auto moving test from ./src to ./dist-test
- 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"