1.0.2 • Published 7 years ago

@openmeny/retract v1.0.2

Weekly downloads
1
License
ISC
Repository
-
Last release
7 years ago

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);
    });