Built complex, efficient, composable React components without the need for classes, state, lifecycle methods, or higher-order components!
Refluent is an alternative fluent (chainable) API for React components, which lets you express any component as a series of steps which transform the flow of props (starting with those provided to the component), and gradually output dom.
const Link = refluent
.do('active', active => ({
background: active ? 'grey' : 'white',
}))
.yield(({ text, href, background }) => (
<a href={href} style={{ background }}>
{text}
</a>
));
yarn add refluent
- Features
- Advantages over standard React components
- Component API
- Utility:
branch
- Full example
- Motivation: Beyond higher-order components
Stateful: use push
within do
to update props asynchronously
const Input = refluent
.do('initial', (initial = '', push) => ({
value: initial,
onChange: value => push({ value }),
}))
.do('submit', 'value', (submit, value) => ({
onKeyDown: e => {
if (e.keyCode === 13) submit(value);
},
}))
.yield(({ value, onChange, onKeyDown }) => (
<input value={value} onChange={onChange} onKeyDown={onKeyDown} />
));
Composable: call a component with yield
to use its steps
const Hover = refluent.do((_, push) => ({
hoverProps: {
onMouseMove: () => push({ isHovered: true }),
onMouseLeave: () => push({ isHovered: false }),
},
isHovered: false,
}));
const HoverButton = refluent
.yield(Hover)
.yield(({ text, onClick, hoverProps, isHovered }) => (
<p
onClick={onClick}
{...hoverProps}
style={{ background: isHovered ? 'darkred' : 'red' }}
>
{text}
</p>
));
As demonstrated in the last example above, all Refluent components are automatically composable. Simply calling yield
on an existing component is exactly the same as applying its chain of methods directly (see the docs below). This makes refactoring trivial, and removes the need for the further abstraction of higher-order components.
Another thing Refluent makes trivial is working with the lifecycle of specific props while ignoring the others (i.e. rather than working with the component lifecycle, and hence all the props together). This is especially useful when you want to do something complicated based on a specific prop, such as fetch data or subscribe to a stream of some sort:
refluent
.do('chatId', (chatId, push) => {
const unwatch = watchChat(chatId, messages => push({ messages }));
return () => unwatch();
})
.yield(({ messages, ...otherProps }) => ...);
Beyond specific uses however, Refluent ultimately lets you conceptualize and build components in a completely different (potentially more intuitive) way compared to standard React. Rather than working with components as stateful objects which are pieced together like building blocks, you instead write components as dom-generators - mini imperative programs consisting of calls to do
which build up the logic of your component and calls to yield
which output dom. Read through the full example to see this in action.
The default export of Refluent is a functional React component, extended with three methods do
, yield
and transform
, which create higher-order components from the base component, extended in the same way (and hence allowing for chaining).
The do
method transforms the flow of props, by selecting (similar to Reselect) from the incoming props, and using these to generate additional ones (or changes to existing ones) to pass forward. There are two forms / overloads.
The generation of new props can be either:
- Sync: new props are effectively derived properties of the incoming props
- Async: new props are set / updated in response to events, data fetching etc
The basic form accepts a number of selectors, along with a map which generates the new props, called whenever the selected values change.
do(
selector1,
selector2,
...
map: (value1, value2, ..., push, isCommit) => result
)
Numeric or string value treated as a prop key (optionally nested like a.b
), or an arbitrary selector map.
The body of the do
method, called whenever the selected values change.
Call to 'push' new props forward. The resulting props from do
are the incoming props merged together with all the pushed props (in order if push
is called multiple times). To clear an incoming prop, push an update to it of undefined
.
If provided, callback
will be called when the component has updated with the new props.
True if map
was called from the 'commit' phase (see React async rendering). This is useful as some actions, such as data fetching, are only suitable during the 'commit' phase.
Either void
or:
- An object: treated as props to push forward, exactly as if
push
had been called - A function: called immediately before
map
is next called, or when the component is unmounted (use to clean up any side effects)
The advanced form is similar, but in a sense one 'layer up' - instead of passing the selectors and map directly, you pass a function which uses the provided props$
argument to do so.
The key benefit of this form is that you gain access to a function closure around the selectors and maps.
do(
func: (
props$:
| (
selector1,
selector2,
...
map: (value1, value2, ..., isCommit) => result
)
| (getPushed?) => props,
push,
isCommit
) => result
)
Called when the component is initiated.
Like map
, the result of func
is either void
or:
- An object: treated as props to push forward, exactly as if
push
had been called - A function: called when the component is unmounted
The first overload is exactly equivalent to calling the basic form of do
, except that push
isn't passed to map
since it's already available from the arguments of func
. If this is used at all, it must be done synchronously within func
.
The second overload allows direct access to both the incoming and pushed props (pass getPushed = true
for the latter). This will error if called directly from a map
, you must use selectors instead to access props there.
Note: For optimization purposes, the second overload is only available if func
is also called with push
. If you need it, but don't need push
, just write .do((props$, _) => ...)
.
Refluent maintains a cache of all pushed props, which newly pushed ones are replaced with if they are equivalent by value (for dates, JSON etc). In addition, all pushed props which are functions are wrapped in static 'caller' functions, which stay the same across the component's lifetime. All initial props are also cached and modified in this way.
This means props automatically change as little as possible, and can be compared for equality by reference rather than by value during memoization (see below).
Note: If a function prop is going to be called immediately during a do
or yield
call (e.g. a render prop), then give it a property of noCache: true
to stop it being wrapped, otherwise your component wont re-render when it changes.
The reason do
uses selectors and only allows for merging new props, rather than just accepting an arbitrary map props => props
, is so that map
can be memoized. Due to caching (see above), the selected values can be compared to their previous values by reference, and map
is only called when one or more values have changed.
This means you never have to worry about pure components or shouldComponentUpdate
, as updates only happen when they need to.
Selectors have access to the pushed props from the do
call they are in. This is useful in various circumstances, but naturally creates a potential circular reference - pushed values can be selected and then used to update the same pushed values. However, due to memoization and caching, this will be fine as long as the intent of your map
makes sense, i.e. if any circular references quickly converge to a static value.
To allow effective memoization, and to work with React async rendering, Refluent needs to carefully control side effects. Specifically, if a map
is called from do
, it either needs to be side effect free, or it needs to return a function which cleans up any active side effects.
Note: Don't make any assumptions of when map
will be called, it will likely be different to what you expect, due to both memoization and async rendering.
Conceptually, the yield
method uses the incoming props to output dom, just like a functional React component.
In reality, yield
accepts any React component (i.e. also standard React class components), which it calls with the incoming props, combined with a special render prop called next
(as long as another method is chained after this yield
), which is used to continue the component.
yield(
component: Component<{
...props,
next:
| () => dom
| (nextProps | props => nextProps, doCache?) => dom,
}>
)
Calling next
renders the continuation of the component (i.e. further do
, yield
and transform
calls) at that location within the rendered dom.
The arguments for next
control which props are sent forward, and whether they are cached first. If no props are provided the incoming ones are re-used, otherwise new ones are provided (either directly, or as a map of the incoming props), and are optionally cached (set doCache = true
).
Intuitively, every component must end with a yield
, otherwise you have transformed props with a do
but aren't doing anything with them. Hence, if the last method called on your component isn't a yield
, a default one is applied automatically, which does the following:
refluent
...
.yield({ next, children, ...props }) => {
if (next) return next({ ...props, children });
if (typeof children === 'function') return children(props);
return children || null;
})
I.e. next
is used if present (important for composition, see below), then children
(called if a render prop), or finally just null
.
As yield
accepts any component, this can be used for composition. Specifically, with the help of the default yield
, the following are equivalent:
refluent
.a(...)
.b(...)
...
refluent
.yield(
refluent
.a(...)
.b(...)
...
)
I.e. any chain of steps in a Refluent component can be refactored out into a new component, and called with yield, with the exact same effect.
Using Refluent removes the need for higher-order components, but for compatability with other libraries the transform
helper method is provided.
transform(
hoc: (component: Component) => Component
)
Alongside the main component API, Refluent also comes with a helper utility called branch
.
branch(
test: selector,
truthy: Component,
falsy?: Component
) => Component
This creates a component which applies the test
selector (equivalent to selectors in do
) to the incoming props to choose which of truthy
or falsy
to render. If the selected value is falsy and falsy
isn't provided, the default yield
component is used.
Together with yield
, this allows Refluent components to express (potentially nested) branching if/else logic.
Here we create a component which renders a text field, with optional initial label and hoverable submit button, which will only call submit (on clicking the button or hitting enter) if the value is below 100 characters.
import * as React from 'react';
import refluent, { branch } from 'refluent';
// Create Hover utility
const Hover = refluent.do((_, push) => ({
hoverProps: {
onMouseMove: () => push({ isHovered: true }),
onMouseLeave: () => push({ isHovered: false }),
},
isHovered: false,
}));
const ShortInput = refluent
// 1: Create value prop and onChange handler to manage input state
.do('initial', (initial = '', push) => ({
value: initial,
onChange: value => push({ value }),
}))
// 2: Wrap provided submit handler to enforce value length and return value
.do((props$, _) => ({
submit: () => {
const { submit, value } = props$();
if (value.length < 100) submit(value);
},
}))
.yield(
// 3: If withButton is truthy, branch and...
branch(
({ withButton }) => withButton,
refluent
// 3a: Call Hover utility
.yield(Hover)
// 3b: Change button background color if hovered
.do('isHovered', isHovered => ({
background: isHovered ? 'red' : 'orange',
}))
// 3c: Render button, and call next to continue (and hence render input)
.yield(({ submit, hoverProps, background, next }) => (
<div>
{next()}
<p onClick={submit} {...hoverProps} style={{ background }}>
Submit
</p>
</div>
)),
),
)
// 4: Create onKeyDown handler to submit on enter
.do((props$, _) => ({
onKeyDown: e => {
const { submit } = props$();
if (e.keyCode === 13) submit();
},
}))
// 5: Render input
.yield(({ value, onChange, onKeyDown }) => (
<input
type="text"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
));
All the core ideas of Refluent are inspired by parts of Recompose:
Recompose | Refluent |
---|---|
Composing together multiple 'prop transformation' HOCs, e.g. compose(withProps, mapProps, withState, etc...) , and using mapPropsStream |
Seeing components as a flow of transformed props |
Using mapPropsStream instead of class components, with the function closure for the class body, stream observation for lifecycle events, and stream combination for setState |
Class components not required, even for advanced behaviour |
Creating function props with withHandlers instead of withProps to maintain referential equality |
Automatic referential equality for function props |
Outputting dom with the renderComponent HOC and using nest to combine components (as well as the emergence of render props) |
Partial / nested dom output |
The HOC mapProps is a map (propsA => dom) => (propsB => dom) , but actually acts like propsA => propsB (=> dom) |
Some HOCs don't conceptually map one component to another |
After many experiments and iterations, the first four ideas led to a single pair of HOCs (do
and yield
) which could compose
together to create almost any React component. This very small 'API', along with the last idea above, then inspired the trick of directly attaching the HOCs onto a functional component, creating the fluent API and removing the need for (explicit) HOCs altogether.
All ideas, issues and contributions are very welcome!