Skip to content

bradfordlemley/stated-library

Repository files navigation

🎯 Stated Libraries

The Alternative to "State Management", sensibly bridging the gap between function and state.

Build Status Code Coverage PRs welcome License Types

Overview

Stated Libraries are based on observable state -- listeners subscribe to library state. Library state is changed by calling a library method, resulting in unidirectional data flow.

Here's a quick demonstration of the concepts:

todoLib.state$.subscribe(state => console.log(`State is ${JSON.stringify(state)}`));
// out: State is {todos: [], isFetching: false}  <-- current state is emitted upon subscribing
todoLib.addTodo('Drop Redux');
// out: State is {todos: [{title: 'Drop Redux', completed: false, id: 1}], isFetching: false}
todoLib.completeTodo(1);
// out: State is {todos: [{title: 'Drop Redux', completed: true, id: 1}], isFetching: false}

A Stated Library is a completely standalone module, developed and tested independently. Stated Libraries can perform async functionality and cause side effects. No boilerplate, no restrictions, no middleware.

Using Stated Libraries

Stated Libraries are application-framework-agnostic. Observable state is a generic mechanism and can be interfaced to any application framework update mechanism; for example, a React hook.

This example shows a React todo app using a todo stated library. useObservable is a generic React binding that works for any observable state.

const TodoApp = () => {
  const { todos } = useObservable(todoLib.state$);
  const [text, setText] = useState("");
  return <div>
    <input value={text} onChange={ e => setText(e.target.value) } />
    <button onClick={() => todoLib.addTodo(text)}>Add</button>
    <ul>
      {todos.map( ({id, title}) => <li key={id}>{title}</li> )}
    </ul>
  </div>
}

Using Multiple Stated Libraries

Obserservable state is composable and reactive. Observable states can be combined together to produce a new observable state.

Stated Libraries supports a mapState function for composing observable state.

In reactive programming, functions that takes observable(s) as input and create a new observable are called reactive operators. mapState is a reactive operator. Reactive programming frameworks like RxJS include many reactive operators. mapState is the only reactive operator included with Stated Libraries and probably the only operator you'll need.

This example extends the React todo app from above to filter visible todos. Todo library and visiblity library observable states are composed together to create visibleTodos$ observable state.

const visibleTodos$ = mapState(
  [todoLib.state$, visibilityLib.state$],
  ([todoState, visibilityState]) => {
    switch (visibilityState) {
      case 'active':
        return todoState.todos.filter(todo => !todo.completed);
      case 'completed':
        return todoState.todos.filter(todo => todo.completed);
      default:
        return todoState.todos;
    }
  }
);

const TodoApp = () => {
  const { todos } = useObservable(visibleTodos$);
  const [text, setText] = useState("");
  return <div>
    <input value={text} onChange={ e => setText(e.target.value) } />
    <button onClick={() => todoLib.addTodo(text)}>Add</button>
    <ul>
      {todos.map( ({id, title}) => <li key={id}>{title}</li> )}
    </ul>
    <button onClick={() => visiblityLib.setFilter('all')}>Show all</button>
    <button onClick={() => visiblityLib.setFilter('active')}>Show active</button>
  </div>
}

Composable means that composed observable state can be used to compose observable state. This example shows how visibleTodos$ could be combined some search text observable:

// ...
const matchingTodos$ = mapState(
  [visibleTodos$, searchText$],
  ([visibleTodos, searchText]) => visibleTodos.filter(todo => todo.title.includes(searchText))
);

State composition is application-framework-agnostic, meaning that all of the state composition logic can be implemented and tested without the application view and outside any application framework.

Implementing a Stated Library

A Stated Library is an object that includes standard state-related properties (state, state$, stateEvent$, and resetState()) and extends these to include library-specific methods. The full type definition of a Stated Library can be found at @stated-library/interface.

While it's not too difficult to build a Stated Library from scratch, @stated-library/base provides easy helpers.

This example demonstrates one way to create a Todo Stated Library:

// TodoLib.js
import { createStatedLib } from '@stated-library/base';
import cuid from 'cuid';

export default () => createStatedLib(
  // initial state
  {
    todos: [],
    isFetching: false,
  },
  // methods
  base => ({
    addTodo(text) {
      base.updateState(
        {
          todos: base.state.todos.concat({ title, completed: false, id: cuid() }),
        },
        'ADD_TODO'
      );
    },
    completeTodo(id) {
      base.updateState(
        {
          todos: base.state.todos.map(todo => todo.id === id ? { ...todo, completed: true } : todo),
        },
        'COMPLETE_TODO'
      );
    },
    async fetchTodos() {
      base.updateState({ isFetching: true }, 'START_FETCH_TODOS');
      const fetchedTodos = await fetchTodos();
      base.updateState(
        {
          todos: this.state.todos.concat(fetchedTodos),
          isFetching: false,
        },
        'COMPLETE_FETCH_TODOS'
      );
    }
  }),
);

Each library method updates the library's state. The fetchTodos method updates state twice: once synchronously before fetching todos, and once asynchronously after fetching is complete.

Each call to updateState cause the state$ observable to emit a new state and the stateEvent$ observable to emit a new state event. Each call to updateState includes a human-readable reason which does not affect state, but is included in state events and is useful for debugging.

Library method return values are currently only used for testing -- use cases and best practices for library method return values is TBD.

Since each Stated Library is a completely independent object, they are very easy to test. Here's how the Todo library could be tested:

// TodoLib.test.js
import TodoLib from './TodoLib';

test('Initial state', () => {
  const todoLib = TodoLib();
  expect(todoLib.state.todos).toHaveLength(0);
});

test('Adds todo', () => {
  const todoLib = TodoLib();
  todoLib.addTodo('Drop Redux');
  expect(todoLib.state.todos).toHaveLength(1);
  expect(todoLib.state.todods[0]).toMatchObject({
    title: 'Drop Redux',
    completed: false,
  });
});

test('Fetches todos', async () => {
  const todoLib = TodoLib();
  const fetchPromise = todoLib.fetchTodos();
  expect(todoLib.state.isFetching).toBe(true);
  await fetchPromise;
  expect(todoLib.state.isFetching).toBe(false);
  expect(todoLib.state.todos.length).toBeGreaterThan(0);
});

Why Use Stated Libraries?

Ultimately Stated Libraries helps you develop high-quality, well-tested applications more quickly than other solutions. This is mainly because Stated Libraries are easy and fast to develop and test, are truly modular, and are easy to integrate and test together.

Read more about the motivation and design process for Stated Libraries in:

Feature Stated
Libraries
Redux
Easy to learn
Easy to test
Modular 💩
Typescript
100% coverage
No Boilerplate
Framework agnostic 🍆
Time-travel debug
Local state
Large community Needs help
Cool name

Comparisons

The best way to compare solutions is probably to take examples and re-write them with Stated Libraries, making an effort to keep them as apples-to-apples as possible. Metrics like SLOC, performance, etc., can be helpful, too. But, none of the info is definitive, so you have to use your eyes 👀, brain 😧, and crystal ball 🔮.

Here are some examples ported from other solutions:

Full TodoMVC Example

The TodoApp example is a TodoMVC app that demonstrates many features of Stated Libraries, including derived state, memoization, multiple libraries, Redux DevTools, and Local State Hydration.

Try it out here: Edit @stated-library/todoapp, and be sure to check out time-travel debugging with Redux DevTools.

Advanced Topics

Immutable State

Stated Libraries utilize immutable state. Immutable state enables performance optimizations, supports quantized and trackable state changes, supports unidirectional data flow, and is well-established in the React community. Besides javascript itself, familiarity with immutabile data is probably the only pre-requesite knowledge for working with Stated Libraries. Redux docs have great information on immutable data.

Best Practices

Probably the best feature of Stated Libraries is that all application logic can be developed and tested outside the application itself, without views.

  • Each Stated Library should be fully tested independently
  • Global application functionality should be co-located in a single module and tested directly
  • View Components should import global state-related items directly from the single global state module For example:
  src/
    components/
      App.js  <-- import { stuff } from '../state';
    state/
      index.js       <-- global app logic
      index.test.js  <-- global app logic tests
      todo-lib.js
      todo-lib.test.js
      visibility-lib.js
      visibility-lib.test.js

Best practices are very preliminary and expected to evolve significantly.

Best practices and usage of Stated Libraries for local state are still developing.

Global Application Logic

A typically application would utilize a single module for global state and functionality.

The typical global application logic module would:

  • Create global Stated Library instances
  • Compose additional Observable State
  • Implement Business Logic
  • Connect Tooling

...and might look like this:

// state.js
import { from } from 'rxjs';
import { distinctUntilKeyChanged, filter } from 'rxjs/operators';
import { devTools, mapState } from '@stated-library/core';
import createAuthLib from './AuthLib';
import createTodoLib from './TodoLib';
import createVisibilityLib from './VisibilityLib';

const todoLib = createTodoLib();
const visLib = createVisibilityLib();
const authLib = createAuthLib();

// State Composition
const visibleTodos$ = mapState(
  [todoLib.state$, visLib.state$],
  ([todoLibState, visLibState]) => {
    switch (visLibState.visibility) {
      case 'active':
        return todoLibState.activeTodos;
      case 'completed':
        return todoLibState.completedTodos;
      default:
        return todoLibState.todos;
    }
  });

const getFilteredTodos = memoize(
  (todos, searchTerm) =>
    searchTerm != null
      ? todos.filter(todo => todo.title.indexOf(searchTerm) !== -1)
      : todos;
);

const filteredTodos$ = mapState(
  [visibleTodos$, visLib.state$],
  ([visibleTodos, visLibState]) =>
    getFilteredTodos(visibleTodos, visLibState.searchTerm)
);

const appState$ = mapState(
  [todoLib.state$, filteredTodos$],
  ([todoLibState, filteredTodos]) => ({
    todos: filteredTodos,
    addTodo: todoLibState.addTodo,
  }));

// Business Logic
from(todoLib.state$).pipe(
  distinctUntilKeyChanged('authFailed'),
  filter(state => state.authFailed),
).subscribe( () => authLib.refreshAuth() );

from(authLib.state$).pipe(
  distinctUntilKeyChanged('user'),
).subscribe( authState => todoLib.setUser(authState.user) );

// DevTools
devTools.connect(authLib, 'authLib');
devTools.connect(todoLib, 'todoLib');
devTools.connect(visLib, 'visLib');
devTools.connectState(filteredTodos$, 'filteredTodos');
devTools.connectState(visibleTodos$, 'visibleTodos');

export {authLib, todoLib, visLib};

export {visibleTodos$, filteredTodos$};

Testing Global Application Logic

The entirity of global application logic can be tested directly:

// state.test.js
// reset state module for each test
let state;
beforeEach(() => {
  jest.resetModules();
  state = require('./state');
});

test('Visible Todos', () => {
  const { todoLib, visLib, visibleTodos$ } = state;
  todoLib.add('Drop Redux');
  expect(visibleTodos$.value).toHaveLength(1);
  visLib.setFilter('completed');
  expect(visibleTodos$.value).toHaveLength(0);
})

Functions as Part of State

Functions can be included in state and are treated like any other piece of state, no special handling required. This can be convenient because it keeps both state and state-related functions in one place, allowing them to be passed around together. This also supports a cleaner layer of abstraction between functionality and view.

A Stated Library can include functions in its state and/or functions can be included via mapState. All Stated Library methods are required to operate as stand-alone functions so they can be used as state, meaning that they must be pre-bound if they use this.

The following example modifies a TodoApp example from above, including functions in state instead of using library methods directly.

const appState$ = mapState(visibleTodos$, visibleTodos => ({
  todos: visibleTodos,
  addTodo: todoLib.addTodo,
  setVisibility: visibilityLib.setFilter,
}));

const TodoApp = () => {
  const { addTodo, todos, setVisibility } = useObservable(appState$);
  const [text, setText] = useState("");
  return <div>
    <input value={text} onChange={ e => setText(e.target.value) } />
    <button onClick={() => addTodo(text)}>Add</button> // was todoLib.addTodo
    <ul>
      {todos.map( ({id, title}) => <li key={id}>{title}</li> )}
    </ul>
    <button onClick={() => setVisibility('all')}>Show all</button> // was visibilityLib.setFilter
    <button onClick={() => setVisibility('active')}>Show active</button> // was visibilityLib.setFilter
  </div>
}

A function may be constant like a Stated Library method or it can be created with state. If a function depends on the current state, a new function generally needs to be created with each state change. The following example demonstrates:

const appState$ = mapState(
  [visibleTodos$, visibilityLib.state$],
  ([visibleTodos, visLibState]) => ({
    todos: visibleTodos,
    addTodo: todoLib.addTodo,
    toggleVisibility: () => visibilityLib.setVisibility(
      visLibState.visibility === 'all' ? 'active': 'all'
    ),
  }));

const TodoApp = () => {
  const { addTodo, todos, toggleVisibility } = useObservable(appState$);
  const [text, setText] = useState("");
  return <div>
    <input value={text} onChange={ e => setText(e.target.value) } />
    <button onClick={() => addTodo(text)}>Add</button> // was todoLib.addTodo
    <ul>
      {todos.map( ({id, title}) => <li key={id}>{title}</li> )}
    </ul>
    <button onClick={toggleVisibility}>Toggle visibililty</button>
  </div>
}

Derived State

Stated Libraries support derived state transparently and efficiently. State can be derived as part of state composition as shown above. Individual Stated Libraries can also include derived state transparently as part of their state.

@stated-library/base base implementations support derived state by specifying a deriveState function. The deriveState function is called every time state changes.

This example adds completedTodos and activeTodos to the Todo library's state.

// TodoLib.js
import { createStatedLib } from '@stated-library/base';
import createTodo from './createTodo';
import fetchTodosFromCloud from './fetchTodosFromCloud';

function deriveState(rawState) {
  return {
    ...rawState,
    activeTodos: rawState.todos.filter(todo => !todo.completed),
    completedTodos: rawState.todos.filter(todo => todo.completed),
  }
}

const createTodoLib = () => createStatedLib(
  { todos: [] },
  base => ({
   // ... same as above ...
  }),
  { deriveState }
);

export default createTodoLib;

Testing Derived State

Derived state is completely transparent and can be tested just like any other part of state.

// TodoLib.test.js
import TodoLib from './TodoLib';
// ...
expect("Active and completed todos", () => {
  todoLib.addTodo('my first todo');
  todoLib.addTodo('my second todo');
  expect(todoLib.state.activeTodos).toHaveLength(2);
  expect(todoLib.state.completedTodos).toHaveLength(0);
  todoLib.toggleTodo(todoLib.state.todos[0].id);
  expect(todoLib.state.activeTodos).toHaveLength(1);
  expect(todoLib.state.completedTodos).toHaveLength(1);
});
 

Memoization and Getters

To make derived state more efficient, memoization and getters can be used. Memoization is a technique used to achieve performance gains for computations by caching results for previous calculations. Memoization requires a function call, but a getter can be used to make the function call transparent to the client. Additionally, getters are lazy, so the calculation will only be performed if the property is actually used.

// TodoLib.js
import memoize from 'memoize-one';
import { createStatedLib } from '@stated-library/base';
import createTodo from './createTodo';
import fetchTodosFromCloud from './fetchTodosFromCloud';

export default function createTodoLib() {

  const getCompletedTodos = memoize(
    todos => todos.filter(todo => todo.completed)
  );

  const getActiveTodos = memoize(
    todos => todos.filter(todo => !todo.completed)
  );

  function deriveState(rawState) {
    return {
      ...rawState,
      get activeTodos() {
        return getActiveTodos(rawState.todos);
      },
      get completedTodos() {
        return getCompletedTodos(rawState.todos);
      },
    }
  }

  return createStatedLib(
    { todos: [] },
    base => ({

      addTodo(text) {
        base.updateState({
            todos: base.state.todos.concat(makeTodo(text)),
          }, 'ADD_TODO');
      },

      toggleTodo(id) {
        base.updateState({
          todos: base.state.todos.map(todo =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo)
        }, 'TOGGLE_TODO');
      },

      async fetchTodos() {
        base.updateState({ isFetching: true }, 'FETCH_TODOS_START');
        const newTodos = await fetchTodosFromCloud();
        base.updateState({
          todos: base.state.todos.concat(newTodos),
          isFetching: false,
        }, 'FETCH_TODOS_COMPLETE');
      },
    }),
  { deriveState });

Business Logic

Business logic, or "glue" logic, is a layer of functionality that ties together independent functionality modules. For example, a method in ModuleA may need to be invoked when some event or state occurs in ModuleB. A Stated Library can support custom methods to enable interactions with other modules, but it is also possible to achieve such interactions generically by monitoring state$ and/or stateEvent$.

// state.js
import createAuthLib from './AuthLib';
import createTodoLib from './TodoLib';

const todoLib = createTodoLib();
const authLib = createAuthLib();

authLib.state$.subscribe(state => {
  // send auth state updates to todoLib
  todoLib.setUser(state.user);
  todoLib.setLoggedIn(state.loggedIn);
});

todoLib.state$.subscribe(state => {
  // refresh auth when todolib encounters auth-related failure
  if (state.authFailed) {
    authLib.refreshAuth();
  }
});

export { todoLib, authLib };

Reactive Programming Business Logic

Business logic can become quite cumbersome and verbose -- usually state changes need to be detected, requiring extra variables to remember last states, etc.

Reactive programming operators, like those from RxJS, can make the implementation of these interactions much easier. For example, the distinctUntilKeyChanged operator can be used to detect when a particular part of state changes, whereas implementing such functionality by hand requires significantly more code. The purple circle in the diagram represents reactive operator(s). You don't have to use reactive operators, but they generally enable complex functionality to be implemented with very little code.

// state.js
import { from } from 'rxjs';
import { distinctUntilKeyChanged, filter } from 'rxjs/operators';
import createAuthLib from './AuthLib';
import createTodoLib from './TodoLib';

const todoLib = createTodoLib();
const authLib = createAuthLib();

from(todoLib.state$).pipe(
  distinctUntilKeyChanged('authFailed'),
  filter(state => state.authFailed),
).subscribe( () => authLib.refreshAuth() );

from(authLib.state$).pipe(
  distinctUntilKeyChanged('user'),
).subscribe( authState => todoLib.setUser(authState.user) );

Reactive Programming in Stated Library Implementations

The reactive programming discussed above is for implementing interactions between libraries. Reactive programming can be used internally in Stated Library implementations, too. Since Stated Library implementations can implement async functionality and side effects, there is nothing special required, no middleware, just do it.

React Component Props as Observable State

A component's props can converted into an observable props$ and then used like any observable state.

import { useValueAs$ } from '@stated-library/react';

const MyComp = ({ itemId }) => {
  const props$ = useValueAs$(props);
  const item = use(() => mapState(
    [props$, itemsLib.state$],
    ([props,  itemsLibState]) => itemsLibState.items[prop.itemId]
  );
  return <div>{item.desc}</div>
}

This is also supported with connect:

import { mapState } from '@stated-library/core';
import { connect } from '@stated-library/react';

const ItemPres = ({ item }) =>
  <div>{item.desc}</div>

const ItemContainer = connect(props$ =>
  mapState(
    [props$, itemsLib.state$],
    ([props,  itemsLibState]) => ({item: itemsLibState.items[prop.itemId]})
  ))
  (ItemPres);

Tooling Overview

Stated Libraries' stateEvent$ and resetState enable generic tooling like DevTools (time travel debugging), state hydrators (local or SSR), analytics, etc., that works with any Stated Library.

Time-travel Debugging

Stated Libraries can be connected to the Redux DevTools extension to enable time-travel debugging.

Additionally, any state observable can be connected to Redux Devtools, allowing state composition to be monitored as well.

The DevTools extension allows developers to view the state history of all connected Stated Libraries and reset their state to any point in history.

// state.js
import { devTools } from '@stated-library/core';
import createTodoLib from './TodoLib';
import createVisibilityLib from './VisibilityLib';

const todoLib = createTodoLib();
devTools.connect(todoLib, 'todoLib');

const visLib = createVisibilityLib();
devTools.connect(visLib, 'visLib');

Note that the standard time-travel debugging caveat for side-effects applies. Whenever there are side effects involved, resetting to a particular state is not exactly equivalent to the original state because it does not undo side effects. That includes server interactions, etc. There's no support for undoing side effects.

Local Storage

A Stated Library's state can be saved to local storage and then hydrated on start up using the locStorage tooling.

// state.js
import { locStorage } from '@stated-library/core';
import createTodoLib from './TodoLib';
import createVisibilityLib from './VisibilityLib';

const todoLib = createTodoLib();
locStorage.connect(todoLib, '**todolib-state**');

SSR

TBD

Local State

Stated Libraries were originally designed as an alternative to global state management solutions like Redux. All of the concepts apply to local state, too.

This example shows how a local instance of an auto complete library could be used.

// AutoComplete.js
import { mapState } from '@stated-library/core';
import { use } from '@stated-library/react';
import createAutoCompleteLib from './AutoCompleteLib';

const AutoComplete = () => {
  const ({setSearchText, searchText, results}) = use(() => {
    const autoLib = createAutoCompleteLib();
    return mapState(autoLib.state$, state => ({
      searchText: state.searchText,
      results: state.results,
      setSearchText: autoLib.setSearchText,
    }));
  });
  return <div>
    <input onChange={setText} value={searchText} />
    <ul>
      {results.map(opt => <li>{opt}</li>)}
    </ul>
  </div>;
};

export default AutoComplete;

All of the tooling -- time travel debugging, etc. -- can be applied to local state, too.

Use cases and best pracitces for local usage of Stated Libraries is still being developed...

Getting Started

Implementing a Stated Library

It is possible to implement a Stated Library from scratch, but the easiest way to implement a Stated Library is to utilize a base implementation, e.g. from @stated-library/base.

npm install @stated-library/base

// TodoLib.js
import { createStatedLib } from '@stated-library/base';
import createTodo from './createTodo';
import fetchTodosFromCloud from './fetchTodosFromCloud';

const createTodoLib = () => createStatedLib(
  { todos: [] },
  base => ({

    addTodo(text) {
      base.updateState({
          todos: base.state.todos.concat(makeTodo(text)),
        }, 'ADD_TODO');
    },

    toggleTodo(id) {
      base.updateState({
        todos: base.state.todos.map(todo =>
          todo.id === id ? { ...todo, completed: !todo.completed } : todo)
      }, 'TOGGLE_TODO');
    },

    async fetchTodos() {
      base.updateState({ isFetching: true }, 'FETCH_TODOS_START');
      const newTodos = await fetchTodosFromCloud();
      base.updateState({
        todos: base.state.todos.concat(newTodos),
        isFetching: false,
      }, 'FETCH_TODOS_COMPLETE');
    },
  )});

export default createTodoLib;

Testing a Stated Library

A typical Stated Library test invokes a library's methods and verifies the library's state.

// TodoLib.test.js
import createTodoLib from './TodoLib';
let todoLib;

beforeEach(() => todoLib = createTodoLib());

expect("Adds a todo", () => {
  todoLib.addTodo('my first todo');
  expect(todoLib.state.todos[0]).toEqual({
    id: todoLib.state.todos[0].id,
    title: 'my first todo',
    completed: false,
  });
});

expect("Toggles a todo", () => {
  todoLib.addTodo('my first todo');
  todoLib.toggleTodo(todoLib.state.todos[0].id);
  expect(todoLib.state.todos[0]).toEqual({
    id: todoLib.state.todos[0].id,
    title: 'my first todo',
    completed: true,
  });
});

expect("Fetches todos from cloud", async () => {
  const fecthPromise = todoLib.fetchTodos();
  expect(todoLib.state.isFetching).toBe(true);
  
  await fetchPromise;
  expect(todoLib.state.isFetching).toBe(false);
  expect(todoLib.state.todos.length).toBeGreaterThan(0);
});

Testing Composed State

import { getValue } from '@stated-library/core';

// reset state module for each test
let state;
beforeEach(() => {
  jest.resetModules();
  state = require('./state');
})

test('visibleTodos$ contains todos filtered thru visibilityFilter', () => {
  const { todoLib, visLib, visibleTodos$ } = state;
  todoLib.addTodo("First");
  todoLib.addTodo("Second");

  expect(getValue(visibleTodos$)).toHaveLength(2);

  visLib.setVisibility("active");
  expect(getValue(visibleTodos$)).toHaveLength(2);

  todoLib.toggle(todoLib.state.todos[0].id);
  expect(getValue(visibleTodos$)).toHaveLength(1);

});

test('filteredTodos$ contains only todos matching searchTerm', () => {
  const { todoLib, visLib, filteredTodos$ } = state;
  todoLib.addTodo("First");
  todoLib.addTodo("Second");

  expect(getValue(filteredTodos$)).toHaveLength(2);

  visLib.setSearchTerm("F");
  expect(getValue(filteredTodos$)).toHaveLength(1);

  visLib.setSearchTerm("X");
  expect(getValue(filteredTodos$)).toHaveLength(0);

  todoLib.toggle(todoLib.state.todos[0].id);
  expect(getValue(visibleTodos$)).toHaveLength(1);

});

Stated Libraries for any state

Stated Libraries were developed as an alternative to State Management for javascript applications, but they are a general solution for anything that manages state.

If you have a library with pieces of state scattered throughout various object properties, e.g. this.isFetching, and it needs to notify clients when these properties change, consider combining them into a single immutable managed state property and converting it into a Stated Library.

Managing state using a Stated Library is a way to organize state within the library, and also provides some cool features for free, like time-travel debugging.

Packages

Stated Libraries consists of several packages:

Package Description Contains
@stated-library/interface Stated Library Interface defintion (typescript). StatedLibrary
@stated-library/core View-framework agnostic. Observable, operators, and tooling. createObservable, mapState
Tooling: devTools, locStore
@stated-library/base Stated Library base implementations. createStatedLib, StatedLibBase
@stated-library/react React bindings. connect, use, link

API

@stated-library/interface

StatedLibrary

All Stated Libraries implement the StatedLibrary:

type StateEvent<RawState, State, Meta> = {
  event: string,
  meta?: Meta,
  rawState: RawState,
  state: State,
}

interface StatedLibrary<RawState, State, Meta> {
  state: State,
  state$: Observable<State>,
  stateEvent$: Observable<StateEvent<RawState, State, Meta>>,
  resetState: (rawState: rawState, event: string, meta?: Meta),
}

The interface defines the basis of an object that manages state.

  • state: current library state
  • state$: observable that emits state for each state change.
  • stateEvent$: observable that emits StateEvent for each state-related event.
  • resetState: back door to set state.

stateEvent$ and resetState are primarily useful for external tooling like DevTools, SSR, and analytics. StateEvents's event, meta, and rawState provide additional info to tools, and state is the same as state$'s state.

All state-related objects are immutable. rawState should be JSON-serializable, but there are no limitations on state.

state$ will typically emit a state each time stateEvent$ emits a stateEvent, but it is possible that a StateEvent will not affect the state, in which case stateEvent$ would emit, but state$ would not.

Note that the observables defined in the Stated Library Interface are compatible with RxJS observables, but they are not RxJs observables. @stated-library/core implements its own light-weight observables and operators, and there is no dependency on RxJS.

The "inputs" to Stated Libraries are library-specific methods; in other words, a Stated Library extends the Stated Library Interface by adding library-specific methods which serve as the "inputs".

Because all Stated Libraries implement the same interface, the tooling around them is generic and you might not ever need to work with the Stated Library Interface directly.

It doesn't matter how a Stated Library is implemented -- as long as it implements the StatedLibrary, it will work as a Stated Library.

@stated-library/core

createObservable

Creates an observable with a custom implementation that is compatible with RxJS. It is similar to RxJS's BehaviorSubject and is used for the output of mapState and is also used by the StatedLibrary base implementations.

mapState

mapState is the state$ composer -- it takes one or more observables as input and creates a new observable.

  • mapState(observableIn, transform) => observableOut
    • observableIn: observable or array of observables
    • transform: function called every time observableIn emits a state. The result is shallow-compared against the previous result, and, if different, the new result is emitted on observableOut.

If observableIn is an array, transform receives an array of state and is called every time any of the input observables emits a state.

Note: mapState is technically a custom observable operator. It is essentially equivalent to RxJS's combineLatest + map + distinctUntilChanged.

getValue

  • getValue(observable) => value

getValue is a utility function that can be used to retrieve the latest value from an observable. If the observable has a value property, it uses that. If not, it temporarily subscribes to the observable to try to receive the value synchronously which works for observables that work like BehaviorSubject, but not all observables.

devTools

  • devTools.connect(library, key) => { disconnect: () => void }

locState

  • locState.connect(library, key) => {clear: () => void, disconnect: () => void }
  • locState.clearAll()

@stated-library/base

createStatedLib

  • createStatedLib(initialState, methodsOrGetMethods, opts?)
// Counter.js
import { createStatedLib } from '@stated-library/base';

function deriveState(rawState) {
  return {
    ...rawState,
    x10: rawState.counter * 10,
  }
}

const createCounter = () => createStatedLib(
  { counter },
  {
    increment() {
      this.updateState({ counter: this.state.counter + 1 }, 'INCREMENT');
    },
    decrement() {
      this.updateState({ counter: this.state.counter - 1 }, 'DECREMENT');
    },
  },
  { deriveState }
)

methodsOrGetMethods can also be a function which returns "input" methods. This makes for a more functional approach and also provides an opportunity to encapsulate.

// Counter.js
import { createStatedLib } from '@stated-library/base';

function deriveState(rawState) {
  return {
    ...rawState,
    x10: rawState.counter * 10,
  }
}

const createCounter = () => createStatedLib(
  { counter },
  ({ updateState }) => ({
    increment() {
      updateState(state => ({ counter: state.counter + 1 }), 'INCREMENT');
    },
    decrement() {
      updateState(state => ({ counter: state.counter - 1 }), 'DECREMENT');
    },
  }),
  { deriveState }
)

StatedLibBase

// Counter.js
import { StatedLibBase } from '@stated-library/base';

function deriveState(rawState) {
  return {
    ...rawState,
    x10: rawState.counter * 10,
  }
}

class Counter extends StatedLibBase {
  constructor(counter: number = 0) {
    super({ counter }, { deriveState });
    StatedLibBase.bindMethods(this);
  }
  increment() {
    this.updateState({ counter: this.state.counter + 1 }, 'INCREMENT');
  }
  decrement() {
    this.updateState({ counter: this.state.counter - 1 }, 'DECREMENT');
  }
}

@stated-library/react

Stated Libraries supports two ways to bind to React:

connect

connect takes an observable and creates an HOC factory to provide the observable value as props to wrapped components.

  • connect(state$)(component) => HOC

connect is similar to react-redux of the same name, but it doesn't take mapStateToProps/mapDispatch because all of the mapping functionality is done externally.

This example shows how to use connect with a container/presentation components style:

// App.js
const App = ({todos, addTodo}) => {
  return (
    <div>
      <button onClick={() => addTodo("New todo")}>
        Add todo
      </button>
      {todos.map(todo => (
        <div key={todo.id}>
          {todo.title} is completed: ${todo.completed}
        </div>
      ))}
    </div>
  );
};
// App-container.js
import { mapState } from '@stated-library/core';
import App from './App';
import { visibleTodos$, todoLib } from './state';

const appState$ = mapState(
  visibleTodos$,
  visibleTodos => ({
    addTodo: todoLib.addTodo,
    todos: visibleTodos,
  }),
)

export default connect(appState$)(App);

A second form of connect allows state to be calculated using the Hoc's props; for react-redux, this is like using ownProps for mapStateToProps.

  • connect(props$ => state$)(component) => HOC
const ItemPres = ({ item }) =>
  <div>{item.desc}</div>

const ItemContainer = connect(props$ =>
  mapState(
    [props$, itemsLib.state$],
    ([props,  itemsLibState]) => ({item: itemsLibState.items[prop.itemId]})
  ))
  (ItemPres);

Direct Injection

Direct injection means that state$ will be a part of the component's state rather than being provided to the component as props (via an HOC). The benefit of direct injection is that there is no extra component in the React component tree.

use

use is the direct injection mechanism for functional components. It creates a React hook that updates the component whenever the observable emits a new value.

// App.js
import * as React from 'react';
import { mapState } from '@stated-library/core';
import { connect } from '@stated-library/react';
import { todoLib, visibleTodos$ } from './state';

const appState$ = mapState(
  visibleTodos$,
  visibleTodos => ({
    addTodo: todoLib.addTodo,
    todos: visibleTodos,
  }),
)

export default () => {
  const {todos, addTodo} = use(appState$);
  return (
    <div>
      <button onClick={() => addTodo("New todo")}>
        Add todo
      </button>
      {todos.map(todo => (
        <div key={todo.id}>
          {todo.title} is completed: ${todo.completed}
        </div>
      ))}
    </div>
  );
};

useValueAs$(value)

useValueAs$ converts a value into an observable which can then be composed with other observables. This is really only useful for converting props to props$.

import { useValueAs$ } from '@stated-library/react';

const MyComp = ({ itemId }) => {
  const props$ = useValueAs$(props);
  const item = use(() => mapState(
    [props$, itemsLib.state$],
    ([props,  itemsLibState]) => itemsLibState.items[prop.itemId]
  );
  return <div>{item.desc}</div>
}

link

link is the direct injection mechanism for stateful class components. It spreads the observable's value onto the component's state by calling the component's setState method whenever the observable emits a new value. link follows the standard life-cycle subscription mechanism.

// App.js
import * as React from 'react';
import { mapState } from '@stated-library/core';
import { connect } from '@stated-library/react';
import { todoLib, visibleTodos$ } from './state';

const appState$ = mapState(
  visibleTodos$,
  visibleTodos => ({
    addTodo: todoLib.addTodo,
    todos: visibleTodos,
  }),
)

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.link = link(appState$);
  }

  componentDidMount() {
    this.link.connect();
  }

  componentWillUnmount() {
    this.link.disconnect();
  }

  render() {
    const {todos, addTodo} = use(appState$);
    return (
      <div>
        <button onClick={() => addTodo("New todo")}>
          Add todo
        </button>
        {todos.map(todo => (
          <div key={todo.id}>
            {todo.title} is completed: ${todo.completed}
          </div>
        ))}
      </div>
    );
  };

Prior Art

Redux

Many concepts in Stated Libraries are informed by, borrowed from, or outright stolen from Redux.

While many of the concepts used in Redux are brilliant, I personally find developing with Redux to be slow and painful, mainly due to:

  • boilerplate (action/reducer/etc for every little thing)
  • inability to implement complex functionality without strange dependencies on external middleware
  • inability to create truly self-contained modules
  • effort required to test all of the above

RxJS

Observables and reactive operators are concepts from RxJS and ReactiveX. State observables are the heart of Stated Libraries, and the functionality is exactly the same as an RxJS Observable; in fact, they are interoperable with RxJS reactive operators.

Design

Stated Libraries design is based on this View Framework architecture diagram:

Key Points

  • Functionality outputs State
  • Functionality is independent of View
  • Data flow is unidirectional

Stated Libraries are designed around the Key Points above, and additionally for modularity, following the unix philosophy.

<style> .nowrap { white-space: nowrap; } </style>
Component Symbol Description Implemented By View Framework Agnostic
Stated Library A module that implements functionality and outputs state. You + @stated-library/base
View Module A view module in a view framework. You + view framework
State Operator An object that transforms & combines state. @stated-library/core
Framework Binding Converts standard state output to view-framework-specific state input. @stated-library/react,
@stated-library/{view-fmk}

The symbol represents observable state, the mechanism used throughout the system to output state. This standard state output mechanism is the key to making all of the components fit together, allowing components to be added into the system seamlessly.

A Stated Library is an object that takes input using regular object methods and outputs state via observable state.

Observable and reactive operator are reactive programming terms. Reactive Programming is the practice of operating on pushed data, and that is exactly what is happening in Stated Libraries, where the pushed data is state.

State operators form a layer of state composition that sits between Stated Library Functionality Modules and View Modules, transforming and combining state outputs to meet the input requirements of View Modules. The state composition layer allows functionality modules to be combined seamlessly, allowing multiple functionality modules to appear as one.

Multiple view modules can be supported by composing state for each view module:

Anatomy of a Stated Library

A Stated Library is a regular object that implements standard Stated Library properties.

In addition to standard state output, Stated Libraries implement other properties that enable standard tooling.

Property Symbol Type Description Enables Tooling
state State Library's current state.
state$ Observable<State> Emits a State each time the library's state changes.
methods (any) => any Library-specific input methods.
stateEvent$ Observable<StateEvent<State>> Emits a StateEvent for each event affecting state.
resetState (State) => void Sets library state.

A StateEvent includes additional information that is useful for tooling:

Property Type Description
state State The library's new state.
rawState State The library's raw state, does not contain derived state.
reason string Human-readable reason for state change.
meta any Event-specific data.

Generally, state$ emits a State each time stateEvent$ emits a StateEvent; however, it is possible that an event will not affect state, in which case stateEvent$ would emit a StateEvent, but state$ would not emit a State.

The resetState method is used by tooling that hydrates state: e.g. SSR, DevTools, etc.

All of the standard Stated Library properties are officially defined by the Stated Library Interface.

Business Logic

Tooling