Home

Awesome

Tansu

npm build codecov

Tansu is a lightweight, push-based framework-agnostic state management library. It borrows the ideas and APIs originally designed and implemented by Svelte stores and extends them with computed and batch.

Main characteristics:

Implementation wise, it is a tiny (1300 LOC) library without any external dependencies.

Installation

You can add Tansu to your project by installing the @amadeus-it-group/tansu package using your favorite package manager, ex.:

Usage

Check out the Tansu API documentation.

The functional part of the API to manage your reactive state can be categorized into three distinct groups:

writable

api documentation

Writable: A Fundamental Building Block

A writable serves as the foundational element of a "store" – a container designed to encapsulate a value, enabling observation and modification of its state. You can change the internal value using the set or update methods.

To receive notifications whenever the value undergoes a change, the subscribe() method, paired with a callback function, can be employed.

Basic usage

import {writable} from "@amadeus-it-group/tansu";
const value$ = writable(0);

const unsubscribe = values$.subscribe((value) => {
  console.log(`value = ${value}`);
});

value$.set(1);
value$.update((value) => value + 1);

output:

  value = 0
  value = 1
  value = 2

Setup and teardown

The writable's second parameter allows for receiving notifications when at least one subscriber subscribes or when there are no more subscribers.

import {writable} from "@amadeus-it-group/tansu";

const value$ = writable(0, () => {
  console.log('At least one subscriber');

  return () => {
    console.log('No more subscriber');
  }
});

const unsubscribe = values$.subscribe((value) => {
  console.log(`value = ${value}`);
});

value$.set(1);
unsubscribe();

output:

  At least one subscriber
  value = 0
  value = 1
  No more subscriber

derived

api documentation

A derived store calculates its value based on one or more other stores provided as parameters. Since its value is derived from other stores, it is a read-only store and does not have any set or update methods.

Single store

import {writable, derived} from "@amadeus-it-group/tansu";

const value$ = writable(1);
const double$ = derived(value$, (value) => value * 2);

double$.subscribe((double) => console.log('Double value', double));
value$.set(2);

output:

Double value 2
Double value 4

Multiple stores

import {writable, derived} from "@amadeus-it-group/tansu";

const a$ = writable(1);
const b$ = writable(1);
const sum$ = derived([a$, b$], ([a, b]) => a + b);

sum$.subscribe((sum) => console.log('Sum', sum));
a$.set(2);

output:

Sum 2
Sum 3

Asynchronous set

A derived can directly manipulate its value using the set method instead of relying on the returned value of the provided function. This flexibility allows you to manage asynchronous operations or apply filtering logic before updating the observable's value.

import {writable, derived} from "@amadeus-it-group/tansu";

const a$ = writable(0);
const asynchronousDouble$ = derived(a$, (a, set) => {
  const plannedLater = setTimeout(() => set(a * 2));
  return () => {
    // This clean-up function is called if there is no listener anymore,
    // or if the value of a$ changed
    // In this case, the function passed to setTimeout should not be called
    // (if it was not called already)
    clearTimeout(plannedLater);
  };
}, -1);

const evenOnly$ = derived(a$, (a, set) => {
  if (a % 2 === 0) {
      set(a);
  }
}, <number | undefined>undefined);

asynchronousDouble$.subscribe((double) => console.log('Double (asynchronous)', double));
evenOnly$.subscribe((value) => console.log('Even', value));

a$.set(1);
a$.set(2);

output:

Double (asynchronous) -1
Even 0
Even 2
Double (asynchronous) 4

computed

api documentation

A computed store is another variant of a derived store, with the following characteristics:

Switch map

This capability to subscribe/unsubscribe to the dependency allows to create switch maps in a natural way.

import {writable, computed} from "@amadeus-it-group/tansu";

const switchToA$ = writable(true);
const a$ = writable(1);
const b$ = writable(0);

const computedValue$ = computed(() => {
  if (switchToA$()) {
    console.log('Return a$');
    return a$();
  } else {
    console.log('Return b$');
    return b$();
  }
});

computedValue$.subscribe((value) => console.log('Computed value:', value));
a$.set(2);
switchToA$.set(false);
a$.set(3);
a$.set(4);
switchToA$.set(true);

output:

Return a$
Computed value: 1
Return a$
Computed value: 2
Return b$
Computed value: 0
Return a$
Computed value: 4

When switchToA$.set(false) is called, the subscription to a$ is canceled, which means that subsequent changes to a$ will no longer trigger the calculation., which is only performed again when switchToA$ is set back to true.

readable

api documentation

Similar to Svelte stores, this function generates a store where the value cannot be externally modified.

import {readable} from '@amadeus-it-group/tansu';

const time = readable(new Date(), (set) => {
  const interval = setInterval(() => {
    set(new Date());
  }, 1000);

  return () => clearInterval(interval);
});

derived vs computed

While derived and computed may appear similar, they exhibit distinct characteristics that can significantly impact effectiveness based on use-cases:

While computed feels more intuitive in many use-cases, derived excels in scenarios where computed falls short, particularly in managing asynchronous state and providing more granular control over value emissions.

Carefully choosing between them based on specific requirements enhances the effectiveness of state management in your application.

Getting the value

There are three ways for getting the value of a store:

import {writable, get} from "@amadeus-it-group/tansu";

const count$ = writable(1);
const unsubscribe = count$.subscribe((count) => {
  // Will be called with the updated value synchronously first, then each time count$ changes.
  // `unsubscribe` must be called to prevent future calls.
  console.log(count);
});

// A store is also a function that you can call to get the instant value.
console.log(count$());

// Equivalent to
console.log(get(count$));

[!NOTE] Getting the instant value implies the subscription and unsubription on the store:

When called inside a reactive context (i.e. inside a computed), getting the value serves to know and "listen" the dependent stores.

batch

api documentation

Contrary to other libraries like Angular with signals or Svelte with runes, where the callback of a subscription is executed asynchronously (usually referenced as an "effect"), we have maintained the constraint of synchronicity between the store changes and their subscriptions in Tansu.

While it is acceptable for these frameworks to defer these calls since their goals are well-known in advance (to optimize their final rendering), this is not the case for Tansu, where the goal is to be adaptable to any situation.

The problem with synchronous subscriptions is that it can create "glitches". Subscribers and computed store callbacks that are run too many times can create incorrect intermediate values.

Svelte stores resolved the diamond dependency issue, but it does not match all the use-cases.

Let's have a look at the following example:

import {writable, derived} from '@amadeus-it-group/tansu';

const firstName = writable('Arsène');
const lastName = writable('Lupin');
const fullName = derived([firstName, lastName], ([a, b]) => `${a} ${b}`);
fullName.subscribe((name) => console.log(name)); // logs any change to fullName
firstName.set('Sherlock');
lastName.set('Holmes');
console.log('Process end');

output:

Arsène Lupin
Sherlock Lupin
Sherlock Holmes
Process end

The fullName store successively went through different states, including an inconsistent one, as Sherlock Lupin does not exist! Even if it can be seen as just an intermediate state, it is fundamental for a state management to only manage consistent data in order to prevent issues and optimize the code.

In Tansu, the batch is available to defer synchronously the subscribers calls, and de facto the dependent derived or computed calculation to solve all kind of multiple dependencies issues.

The previous example is resolved this way:

import {writable, derived, computed, batch} from '@amadeus-it-group/tansu';

const firstName = writable('Arsène');
const lastName = writable('Lupin');
const fullName = derived([firstName, lastName], ([a, b]) => `${a} ${b}`);
// note that the fullName store could alternatively be create with computed:
// const fullName = computed(() => `${firstName()} ${lastName()}`);
fullName.subscribe((name) => console.log(name)); // logs any change to fullName
batch(() => {
    firstName.set('Sherlock');
    lastName.set('Holmes');
});
console.log('Process end');

output:

Arsène Lupin
Sherlock Holmes
Process end

[!NOTE]

asReadable

api documentation

asReadable returns a new store that exposes only the essential elements needed for subscribing to the store. It also includes any extra methods passed as parameters.

This is useful and widely used to compose a custom store:

import {writable, asReadable} from "@amadeus-it-group/tansu";

function createCounter(initialValue: number) {
  const store$ = writable(initialValue);

  return asReadable(store$, {
    increment: () => store$.update((value) => value + 1),
    decrement: () => store$.update((value) => value - 1),
    reset: () => store$.set(initialValue)
  });
}

const counter$ = createCounter(0);

counter$.subscribe((value) => console.log('Value: ', value));

counter$.increment();
counter$.reset();
counter$.set(2); // Error, set does not exist

output:

Value: 0
Value: 1
Value: 0
(Error thrown !)

asWritable

api documentation

asWritable is almost the same as an asReadable, with the key difference being its implementation of the WritableSignal interface.

It's useful when you want to connect your computed store to the original one, or implement a custom set method. The set method can be passed directly in the second parameter or within an object, similar to the usage in asReadable.

For example:

import {writable, asWritable} from "@amadeus-it-group/tansu";

const number$ = writable(1);
const double$ = computed(() => number$() * 2);
const writableDouble$ = asWritable(double$, (doubleNumber) => {
  number$.set(doubleNumber / 2);
});
/* equivalent to:
  const writableDouble$ = asWritable(double$, {
    set: (doubleNumber) => number$.set(doubleNumber / 2)
  });
*/

writableDouble$.subscribe((value) => console.log('Value: ', value));

writableDouble$.set(2); // Nothing is triggered here, as number$ is already set with 1
writableDouble$.set(4);

output:

Value: 2
Value: 4

Integration in frameworks

Tansu works well with the Svelte framework

Tansu is designed to be and to remain fully compatible with Svelte.

Tansu works well with the Angular ecosystem

Here is an example of an Angular component using a Tansu store:

import { Component } from "@angular/core";
import { AsyncPipe } from '@angular/common';
import { Store, computed, get } from "@amadeus-it-group/tansu";

// A store is a class extending Store from Tansu
class CounterStore extends Store<number> {
  constructor() {
    super(0); // initialize store's value (state)
  }

  // implement state manipulation logic as regular methods
  increment() {
    // create new state based on the current state
    this.update(value => value + 1);
  }

  reset() {
    // replace the entire state with a new value
    this.set(0);
  }
}

@Component({
  selector: "my-app",
  template: `
    <button (click)="counter$.increment()">+</button> <br />

    <!-- store values can be displayed in a template with the standard async pipe -->
    Counter: {{ counter$ | async }} <br />
    Double counter: {{ doubleCounter$ | async }} <br />
  `,
  standalone: true,
  imports: [AsyncPipe]
})
export class App {
  //  A store can be instantiated directly or registered in the DI container
  counter$ = new CounterStore();

  // One can easily create computed values by specifying a transformation function
  doubleCounter$ = computed(() => 2 * get(this.counter$));
}

While being fairly minimal, this example demonstrates most of the Tansu APIs with Angular.

Contributing to the project

Please check the DEVELOPER.md for documentation on building and testing the project on your local development machine.

Credits and the prior art