@openmeny/retract v1.0.2
Retract
Q: Uuuuuhhhh so what? Another state manager? Y U NO REDUX?
A: Yeah, we ask ourselves that some times.
We started this project much before Redux. Of course its API transformed and has much in common with other state management libraries, yet it works like magic, at least on our use case.
Even with Redux comming to be very popular, it is not time yet for a transition. Plus Retract gives us no trouble today, has a simple API, strong code style and worths the open source initiative.
Q: So you think that this may be a sucessful model and the community could help it to grow?
A: We can't tell, it is to hard to adapt a framework to suit in every work scenarios.
There are some questions that we believe Retract is on the way to solve. But on the other hand, nothing will ever be perfect.
This question can be answered by the community itself. We have no reason to mind about it by now.
Q: So what are the key concepts you built into this?
A: If you are familiar with Redux thats an easy question:
- Single store available for every part of the app.
- The state is just an ImmutableJS Map which we control the access to.
- The store exports an update method:
set('path.sub.x', value)
. - It also has a read method:
get('path.sub.y')
. - Every update is sync.
- Side effects are created from the state update itself (?
reaction([ 'router.path' ]).observable.subscribe(...)
).
Thats it. Now, the React part. It is simple, yet we are pretty rigid and opinionated.
- Every components tracks some subset of the state. If your page needs to now the books, track
state.books
. - If you need to keep track of a single book, track
state.books.{id}
- You can track any number of items.
- "Tracks" can be setup dinamically.
- By default, nothing ever updates unless the state has changed and the component tracks the cange of thar subset of the state.
- The use of
this
is not allowed inside components
Why?
The problem is not how to organize the state of the whole app. The key concept here is that true reactivity and reusability is only achieved when you tell components where to search for their data, istead to expecting them to just use what you give them. Thats easy to do with Retract
Working Example
We have a record, inside that record we have orders.
In this scenario, we are creating a order listing component that render the record orders and the ongoing new order.
import uuid from 'uuid';
import React from 'react';
import { render } from 'react-dom';
import { Store, store, create, createAction } from 'engine/retract';
export const initial_state = {
router: {
params: { record: 'r1' }
},
record: {
'r1': { id: 'r1', orders: [ 'o1', 'o2' ] }
},
order: {
'o1': {
id: 'o1',
items: {
'p1': { quantity: 2 },
'p2': { quantity: 1 },
}
},
'o2': {
id: 'o2',
items: {
'p1': { quantity: 1 },
'p2': { quantity: 2 },
}
}
},
product: {
'p1': { id: 'p1', name: 'Burguer' },
'p2': { id: 'p2', name: 'Fries' }
}
};
/* boots up the store */
Store('App', initial_state, true);
/* Actions */
export const [addToOrder, onAddToOrder] = createAction({ name: 'ui.order.new_product' });
onAddToOrder
.debounce(200)
.subscribe(([ order_path, product ]) => (
store().set(
`${order_path}.items.${product}.quantity`,
(quantity = 0) => quantity + 1
)
));
export const [addOrder, onAddOrder] = createAction({ name: 'ui.order.new' });
onAddOrder
.debounce(200)
.filter(([ order_path ]) => (
store().get(`${order_path}.items`)
))
.subscribe(([ order_path, record ]) => {
const order_id = uuid();
const order = store().get(`${order_path}.items`);
store().set(
`order.${order_id}`,
{ id: order_id, items: order }
);
store().set(
`record.${record}.orders`,
(orders = []) => orders.push(order_id)
);
store().set(order_path, { });
});
export const [clearNewOrder, onClearNewOrder] = createAction({ name: 'ui.order.clear' });
onClearNewOrder
.subscribe(order_path => {
store().set(order_path, { });
});
/* Components */
export const ProductItem = create(({
product,
quantity
}) => (
<div>{quantity}x {product.name}</div>
));
export const OrderList = create(({
$$order,
order: {
id,
items = { }
} = { }
}) => (
<div style={{ marginBottom: 8, paddingBottom: 8, borderBottom: '1px solid #999' }}>
{Object.keys(items).map(product => (
<ProductItem.el
key={product}
$$product={`product.${product}`}
$$quantity={`${$$order}.items.${product}.quantity`}
/>
))}
</div>
));
export const RecordPage = create(({
router: {
params: { record }
}
}) => (
<div>
<h3>Current orders</h3>
{store().get(`record.${record}.orders`).map(order =>
<OrderList.el
key={order}
$$order={`order.${order}`}
/>
)}
<h3>New order</h3>
<OrderList.el
$$order={`record.${record}.new_order`}
/>
<div style={{ marginBottom: 8 }}>
<button onClick={addToOrder.bind(null, [`record.${record}.new_order`, 'p1'])}>
Add more of 'p1'
</button>
</div>
<button onClick={clearNewOrder.bind(null, `record.${record}.new_order`)}>Cancel</button>
<button onClick={addOrder.bind(null, [`record.${record}.new_order`, record])}>
Finish order
</button>
</div>
));
render((
<div>
<RecordPage.el
_track={[ ['record', store().get('router.params.record'), 'orders'] ]}
router={store().get('router').toJS()}
/>
</div>
), document.getElementsByTagName('react')[0]);
Explanation - Components API
The create
api returns a new component under OrderList.el
.
The component created that way is decorated with Retract store connection.
export const OrderList = create(({
$$order,
order: { ... } = { }
}) => (
<div style={{ ... }}>
{...}
</div>
));
The component expects an order
object and $$order
which is the path on the state of that order
<OrderList.el
key={order}
$$order={`order.${order}`}
/>
When a Retract component is called like the code above, the engine populates the components props from every props.$$var
to props.var
.
In this case, the value of order
on the OrderList component will be the value of order.${order}
on the global state.
Also, the engine subscribes the component to that path, so that it now watches for changes on this subset of the state (and only from there).
Any other prop is transported as is to the component. eg:
<RecordPage.el
_track={[ ['record', store().get('router.params.record'), 'orders'] ]}
router={store().get('router').toJS()}
/>
router
is transfered to the component as a normal functional component would do.
Note that changes to router
wont reflect as an update to the RecordPage component. Even if the parent did update.
Components can also watch multiple subsets of the state just like the ProductItem component
export const ProductItem = create(({
product,
quantity
}) => (
<div>{quantity}x {product.name}</div>
));
It is possible to ask the component to track other paths with the special prop _track
.
It allows an array of paths. Paths are also arrays, each item is a nested path on a Map.
Dot notation is also allowed here _tracks={[ ['router.params.record'] ]}
.
<RecordPage.el
_track={[ ['record', store().get('router.params.record'), 'orders'] ]}
/>
Explanation - Actions API
The createAction
API returns two values:
[
actionDispatcher, // The function that should be called by the UI
actionEffect // A RX Observable that streams the actions dispatched
]
In the example, the UI bound the addToOrder
to a button click event.
Meanwhile we subscribe to onAddToOrder
, debouncing events to prevent double clicks, and updating the state with the new information gathered from the action.
export const [addToOrder, onAddToOrder] = createAction({ name: 'ui.order.new_product' });
onAddToOrder
.debounce(200)
.subscribe(([ order_path, product ]) => (
store().set(
`${order_path}.items.${product}.quantity`,
(quantity = 0) => quantity + 1
)
));
Check the console for helpful logs!
Reactions
Reactions are ways to recalculate state based on new data. You can watch multiples subsets of information and transform them into another helpful state to your components.
Useful to make expensive calculations, react to an unrelated state like route parms to structured data, join data, etc.
The reaction API
reaction( tracks ): Observable
Example: Calculating and exposing a value from the current state to another attribute.
reaction([ ['new_order.items'] ])
.observable
.subscribe(ev => {
const { get, set } = store();
const order_items = get('new_order.items');
const order_total = (order_items && order_items.size > 0) ? (
order_items.reduce((sum, qtd, item) =>
sum + (qtd * get(`products.${item}.price`)), 0)
) : 0;
set('view.new_order_total', order_total);
});