roox

Immutable, object-oriented state manager for React.

Usage no npm install needed!

<script type="module">
  import roox from 'https://cdn.skypack.dev/roox';
</script>

README

Roox

Immutable, object-oriented state manager for React.

This Document is outdated.

Table of contents

Introduction

Roox is a simple state manager for React, and combines immutable state with object-oriented programming.

Roox is implemented in typescript, and all code in this document is typescript code. But you can also use es6 instead.

The core concept of Roox is RooxState

  • RooxState is immutable.

  • RooxState can define reducer, which is the only way to update state.

Roox is easy to use, let's look at a simple example ,and then introduce Roox by explaining this example.

Example: Counter

code structure

  • src

    • components
      • Counter.tsx
    • states
      • CounterState.ts
    • index.tsx

CounterState.ts

import { RooxState, reducer } from 'roox';

type P = Partial<CounterState>;

export class CounterState extends RooxState {
    counter = 0;
  
    @reducer
    inc(): P {
        return { counter: this.counter + 1 }
    }

    @reducer
    dec(): P {
        return { counter: this.counter - 1 }
    }

    @reducer
    add(n: number): P {
        return { counter: this.counter + n }
    }

    incIfOdd(): P {
        return this.isOdd() ? this.inc() : this;
    }

    incAsync() {
        let track = this.track();
        setTimeout(() => track.inc(), 1000);
    }

    isOdd() {
        return this.counter % 2 === 1;
    }
}

Counter.tsx

import * as React from 'react';
import { CounterState } from '../states/CounterState';

export function Counter(props: {counterState: CounterState}) {
    const { counterState } = props;
    return <p>
        Clicked: {counterState.counter} times{' '}
        <button onClick={() => counterState.inc()}>+</button>{' '}
        <button onClick={() => counterState.dec()}>-</button>{' '}
        <button onClick={() => counterState.incIfOdd()}>Increment if odd</button>{' '}
        <button onClick={() => counterState.add(2)}>add 2</button>{' '}
        <button onClick={() =>counterState.incAsync()}>Increment async</button>{' '}
    </p>
}

index.tsx

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Store } from 'roox';
import { Counter } from './components/Counter';
import { CounterState } from './states/CounterState';

const store = new Store(new CounterState());

function render() {
  ReactDOM.render(
    <Counter counterState={store.getState()} />,
    document.getElementById('root')
  )
}

store.subscribe(() => {
  render();
})

render();

RooxState

At first, we define a class named CounterState, which extends RooxState. RooxState is the core of Roox. In the following, when we referer RooxState, we mean all objects of RooxState and its derived class.

RooxState is immutable, so you can't modify its property. In fact, when a RooxState is added to the store, Roox will call Object.freeze to ensure its immutability.

RooxState has two impotant features:

  • define reducer.
  • call track.

reducer

The only way to update RooxState is to define and call a reducer. when you call a reducer, the reducer will produce a new RooxState, which replace the old RooxState.

define reducer

defining a reducer is just like defining a normal method, but you must annotated the reducer with @redcuer (@reducer is a syntax of es6 and typescript called decorator).

In the Counter example, CounterState has 3 reducers: inc, dec and add, which modify the value of counter: number.

A reducer can return one of the following types of value

  • a new RooxState.

  • a diff. Roox will patch the diff to this, and produce a new RooxState.

A diff is a plain object, and its key must be a property name of this .Roox will ensure this at runtime, using code like this: if (!(key in this)) throw xxx. If you use typescript, it's recommended to annotated the return type with Partial<xxxState>, then typescript can ensure the diff is legal at compile time.

In the Counter example, we return a diff of shape { counter: xxx }, which means the only property we mean to update is counter. the inc reducer is equal to the following code which just return a new CounterState:

@reducer inc() {
  let newCounterState = new CounterState();
  newCounterState.counter = this.counter + 1;
  return newCounterState;
}

you must initialize RooxState properties before call its reducer. In typescript, declaring a property without initialing it won't generate any code after compile, which will cause Roox throw error when check the diff that a reducer returned.

class CounterState extends RooxState {
  counter; // wrong: counter must be initialized, like counter = 0;
  // ......
}

If you want to add property danamicly, you can use RooxMap.

Definition of a reducer should be pure.

call reducer

Calling a reducer is just like calling a normal method, but the @reducer decorator does add additional semantic. Although reducer definition is pure, calling a reducer does have side effect : update the state tree, and @reducer does the magic.

After you call a reducer, it produce a new RooxState, either just use the return value or patch the diff it returned to the old RooxState. Roox create a new immutable state tree using this new RooxState with all ancestor states copyed and updated, and the old state tree is detached from the store.

The following is the most impotant thing to remember when you call a reducer.

reducer can only be called on a RooxState in the store.

It means that you can only call reducers once on a specific RooxState. The following code is wrong:

counterState.inc(); // After this call, counterState1 has been removed from store
counterState.dec(); // Wrong.

Ancestor RooxStates are also updated when you call a reducer.

class AppState {
  @reducer foo() { ... }
  counterState = new CounterState();
}
......
let counterState = appState.counterState;
counterState().inc();
// Wrong, appState has been removed from store because we call reducer on counterState.
appState.foo();

Calling a reducer always returns a new RooxState. When the reducer definition returns a diff, the actual return value is the new state patched from the diff. @reducer does the magic. so you can write code like this:

// Annotate counterState with type Partial<CounterState> because reducer annotated return type of Partial<CounterState>.
// In fact, it's always a full CounterState.
let Partial<CounterState> counterState = store.getState(); 
counterState = counterState.inc();
counterState = counterState.dec();

We can also write like this:

counterState.inc().dec();

Don't call reducer on RooxState in async code. Because you can't ensure the RooxState is in store when aysnc code runs.

setTimeout(() => {
  // Wrong, counterState may have been removed from store when timer fired.
  counterState.inc();
}, 1000);

You can use track in async code.

track

Let's recap the incAsync method of CounterState

    incAsync() {
        let track = this.track();
        setTimeout(() => track.inc(), 1000);
    }

Here we use another feature of RooxState: track.

Calling track on a RooxState will returns a proxy object. Sometimes we also call this proxy object track for simplicity.

This proxy object can do anything the RooxState can do, such as calling a reducer. calling a reducer won't invalidate the proxy object, so we can write like this:

let track: Track<CounterState> = counterState.track();
track().inc();
track().dec();

the proxy object track returned contains two information

  • store:a tree of RooxState.
  • path: a path in the tree.

For example, if we have a state tree like the following:

class AppState {
  innerState = new InnerState();
}
class InnerState {
  counterState = new CounterState();
}

Then the path of counterState is /innerState/counterState.

Everytime we do something on a the proxy object, Roox will get the fresh RooxState by following the path in the store. The following code

let track = store.getState().innerState.counterState.track();
track.inc();
track.dec()

is equal to

store.getState().innerState.counterState.inc();
store.getState().innerState.counterState.dec();

How Roox works

The key idea of Roox is simple: state shapes a tree, and a path in the tree can act as the identify of an object. We think different RooxStates in the same path as different values of a object. This way of thinking makes it possible to combine immutable state with object-oriented programming.

How Roox works:

  1. RooxStates shape into a immutable tree.
  2. A RooxState can only appear once in the tree, so there is a one-one mapping betwen a RooxState and a tree node in the tree.
  3. Properties of the RooxState act as the children of the tree node.
  4. Calling a reducer will produce a new RooxState, which is placed into the tree node that the old RooxState was mapping to, and a new immutable state tree is created.

API

Store<T extends RooxState>

A Store holds a immutable state tree, and you can listen to state changes using subscribe.

constructor(initialState: T)

initialState can't be null.

getState(): T;

Get the root of the RooxState tree.

subscribe(listener: () => any): () => void;

Listen to state changes.

RooxState

@reducer
track(option?: TrackOption<this>): Track<this>

Get a proxy object pointing to the tree node this mapping to.

inStore(): boolean

Check if this is in the store.

RooxArray<T>

A subclass of RooxState which wraps JavaScript Array.

constructor(public data: T[])
data: T[]

Get the wrapped array object.

@reducer
callReducer(callback: (data: T[]) => T[])

Update data with the return value of callback. the argument passed to callback is the old data.

@reducer
set(index: number, value: T)

Update a member of array.

@reducer
push(...items: T[])

Wrap Array.push.

@reducer
pop()

Wrap Array.pop.

@reducer
shift()

Wrap Array.shift.

@reducer
unshift(...items: T[])

Wrap Array.unshift.

@reducer
splice(start: number, deleteCount?: number, ...items: T[])

WrapArray.splice.

get(index: number)

Get a member of array.

length: number

wrap Array.length.

RooxMap<K extends string | number, V>

A subclass of RooxState which wraps JavaScript Map. The type of key can only be string or number.

constructor(public data: Map<K, V>)
data: Map<K, V>

Get the wrapped Map.

@reducer
callReducer(callback: (data: Map<K, V>) => Map<K, V>)

Update data with the return value of callback. the argument passed to callback is the old data.

@reducer
set(key: K, value: V)

Add or update a member of map.

@reducer
delete(key: K)

Delete a member of map.

@reducer
clear()

Clear map.

get(key: K)

Get a member of map.

RooxWrapper<T>

A subclass of RooxState which wraps non-roox data. T must not contains RooxState.

The following is wrong:

// Wrong, call reducer on CounterState will not work.
RooxWrapper(Immutable.List<CounterState>)

Because CounterState can't placed into RooxWrapper.

constructor(public data: T)
data: T

The wrapped data.

@reducer
callReducer(callback: (data: T) => T) 

Update data with the return value of callback. the argument passed to callback is the old data.

Track<T>

A proxy object pointing to a specific path of the RooxState tree.

You can get a Track object by calling track method on a RooxState.

$getTrackedState(): T;

Get the RooxState this proxy object pointing to.

$isCheckFailed(): boolean;

Check if check failed.

$notNull(): boolean;

Check if the path has a valid RooxState.

$isNull(): boolean;

Equal to !$isNull.

Compare with Redux

Roox is inspired by Redux, and motivated by the inconvinience of Redux.

Same with Redux

  • Immutable state
  • Single store
  • Pure reducer

Difference

  • Object-oriented style programming. Just define and call reducer, no needs for action.
  • No combineReducer.
  • No connect.Roox is almost othonomal with React.
  • No mapStateToProps / mapDistatchToProps, Roox encourage you to pass state as React props.
  • Forced immutable State.

LICENSE

MIT