diff --git a/README.md b/README.md index 4b87c75..0329cbd 100644 --- a/README.md +++ b/README.md @@ -17,83 +17,318 @@ Main characteristics: Implementation wise, it is a tiny (1300 LOC) library without any external dependencies. -## Comparison with the Svelte stores +## Installation -Tansu is designed to be and to remain fully compatible with Svelte. Nevertheless, it brings several improvements: +You can add Tansu to your project by installing the `@amadeus-it-group/tansu` package using your favorite package manager, ex.: -### Tansu works well with the Angular ecosystem +* `yarn add @amadeus-it-group/tansu` +* `npm install @amadeus-it-group/tansu` -* works with the standard `async` pipe out of the box -* stores can be registered in the dependency injection (DI) container at any level (module or component injector) -* stores can be used easily with rxjs because they implement the `InteropObservable` interface -* conversely, rxjs observables (or any object implementing the `InteropObservable` interface) can easily be used with Tansu (e.g. in Tansu `computed` or `derived`). +## Usage -### A computed function is available +Check out the [Tansu API documentation](https://amadeusitgroup.github.io/tansu/). -With Svelte `derived` function, it is mandatory to provide explicitly a static list of dependencies when the store is created, for example: +The APIs to manage your reactive state can be categorized into three distinct groups: + + - Base store: `writable` + - Computed stores: `derived`, `computed`, `readable` + - Utilities: `batch`, `asReadable`, `asWritable` + +### writable + +[api documentation](https://amadeusitgroup.github.io/tansu/functions/writable.html) + +**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 ```typescript -import {writable, derived} from 'svelte/store'; +import {writable} from "@amadeus-it-group/tansu"; +const value$ = writable(0); -const quantity = writable(2); -const unitPrice = writable(10); -const totalPrice = derived([quantity, unitPrice], ([quantity, unitPrice]) => { - console.log("computing the total price"); - return quantity > 0 ? quantity * unitPrice : 0 +const unsubscribe = values$.subscribe((value) => { + console.log(`value = ${value}`); }); -totalPrice.subscribe((totalPrice) => console.log(totalPrice)); // logs any change to totalPrice -quantity.set(0); -unitPrice.set(20); + +value$.set(1); +value$.update((value) => value + 1); ``` -The output of this example will be: +output: ```text -computing the total price -20 -computing the total price -0 -computing the total price + value = 0 + value = 1 + value = 2 ``` -Note that even when the quantity is 0, the total is recomputed when the unit price changes. +#### Setup and teardown -In Tansu, while the same [derived](https://amadeusitgroup.github.io/tansu/functions/derived.html) function is still available, the [computed](https://amadeusitgroup.github.io/tansu/functions/computed.html) function is also available, with which it is only necessary to provide a function, and the list of dependencies is detected automatically and dynamically: +The writable's second parameter allows for receiving notifications when at least one subscriber subscribes or when there are no more subscribers. ```typescript -import {writable, computed} from '@amadeus-it-group/tansu'; +import {writable} from "@amadeus-it-group/tansu"; + +const value$ = writable(0, () => { + console.log('At least one subscriber'); -const quantity = writable(2); -const unitPrice = writable(10); -const totalPrice = computed(() => { - console.log("computing the total price"); - return quantity() > 0 ? quantity() * unitPrice() : 0 + return () => { + console.log('No more subscriber'); + } +}); + +const unsubscribe = values$.subscribe((value) => { + console.log(`value = ${value}`); }); -totalPrice.subscribe((totalPrice) => console.log(totalPrice)); // logs any change to totalPrice -quantity.set(0); -unitPrice.set(20); + +value$.set(1); +unsubscribe(); +``` + +output: + +```text + At least one subscriber + value = 0 + value = 1 + No more subscriber ``` -Note that every store created with `store = writable(value)` is a function that returns the value of the store. It is equivalent to `get(store)`. -When getting the value of a store (either by calling the store as a function or by using `get`) from a reactive context (such as the one created by `computed`) the dependency on that store is recorded and any change to that store will trigger a recomputation. +### derived + +[api documentation](https://amadeusitgroup.github.io/tansu/functions/derived.html) + +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. -The output of this example will be: +#### Single store + +```typescript +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: ```text -computing the total price -20 -computing the total price -0 +Double value 2 +Double value 4 ``` -Note that when the quantity is 0, changes to the unit price no longer trigger an update of the total price because the list of dependencies is dynamic (and `unitPrice()` is not called if `quantity()` is `0`). +#### Multiple stores + +```typescript +import {writable, derived} from "@amadeus-it-group/tansu"; -### A batch function is available +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); +``` -Depending on multiple stores can lead to some issues. Let's have a look at the following example: +output: + +```text +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. ```typescript -import {writable, derived} from 'svelte/store'; +import {writable, derived} from "@amadeus-it-group/tansu"; + +const a$ = writable(0); +const asynchronousDouble$ = derived(a$, (a, set) => { + setTimeout(() => set(a * 2)); +}); + +const evenOnly$ = derived(a$, (a, set) => { + if (a !== undefined && a % 2 === 0) { + set(a); + } +}, undefined); + +asynchronousDouble$.subscribe((double) => console.log('Double (asynchronous)', double)); +evenOnly$.subscribe((value) => console.log('Even', value)); + +a$.set(1); +a$.set(2); +``` + +output: + +```text +Even 0 +Even 2 +Double 0 +Double 2 +Double 4 +``` + + +### computed + +[api documentation](https://amadeusitgroup.github.io/tansu/functions/computed.html) + +A `computed` store is another variant of a derived store, with the following characteristics: + + - **Implicit Dependencies:** Unlike in a derived store, there is no requirement to explicitly declare dependencies. + + - **Dynamic Dependency Listening:** Dependencies are determined based on their usage. This implies that a dependency store not actively used is not automatically "listened" to, optimizing resource utilization. + +#### Switch map + +This capability to subscribe/unsubscribe to the dependency stores allows to create switch maps in a natural way. + +```typescript +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: + +```text +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](https://amadeusitgroup.github.io/tansu/functions/readable.html) + +Similar to Svelte stores, this function generates a store where the value cannot be externally modified. + +```typescript +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: + +- **Declaration of Dependencies:** + - `computed`: No explicit declaration of store dependencies is required, providing more flexibility in code composition. + - `derived`: Requires explicit declaration of dependencies. + +- **Performance:** + - `computed`: Better performance by re-running the function only based on changes in the stores involved in the last run. + - `derived`: Re-run the function each time a dependent store changes. + +- **Asynchronous State:** + - `computed`: Unable to manage asynchronous state. + - `derived`: Can handle asynchronous state with the `set` method. + +- **Skipping Value Emission:** + - `computed`: Does not provide a mechanism to skip emitting values. + - `derived`: Allows skipping the emission of values by choosing not to call the provided `set` method. + +- **Setup and Teardown:** + - `computed`: Lacks explicit setup and teardown methods. + - `derived`: Supports setup and teardown methods, allowing actions such as adding or removing DOM listeners. + +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: + +```typescript +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 + // must be unsubscribe to end the 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: +> - It can be important to know in case of setup/teardown functions. +> - In the same scope, prefer to store the value once in a local variable instead of calling `store$()` several times. +> +> `get` can work with Class variant of a 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](https://amadeusitgroup.github.io/tansu/functions/batch.html) + +Contrary to other libraries like Angular with signals or Svelte with runes, where the callback of a subscription is executed asynchronously (with `useEffect`), 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 subscription 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](https://github.com/sveltejs/svelte/pull/2955), but it does not match all the use-cases. + +Let's have a look at the following example: + +```typescript +import {writable, derived} from '@amadeus-it-group/tansu'; const firstName = writable('Arsène'); const lastName = writable('Lupin'); @@ -104,7 +339,7 @@ lastName.set('Holmes'); console.log('Process end'); ``` -The output of this example will be: +output: ```text Arsène Lupin @@ -115,7 +350,7 @@ 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 function](https://amadeusitgroup.github.io/tansu/functions/batch.html) is available to defer **synchronously** (another important point) the derived (or computed) calculation and solve all kind of multiple dependencies issues. +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: @@ -135,22 +370,114 @@ batch(() => { console.log('Process end'); ``` -With the following output: +output: ```text Arsène Lupin Sherlock Holmes Process end ``` +> [!NOTE] +> - Managing stores in a batch context can be quite challenging (getting store values inside a batch, subscribing in a batch, avoiding rerunning calculations outside the batch if the value has been updated inside, ...). All potential situations have been specified in the specs. +> - `batch` can be called inside `batch`. The subscriber calls are performed at the end of the first batch, synchronously. -## Installation -You can add Tansu to your project by installing the `@amadeus-it-group/tansu` package using your favorite package manager, ex.: +### asReadable -* `yarn add @amadeus-it-group/tansu` -* `npm install @amadeus-it-group/tansu` +[api documentation](https://amadeusitgroup.github.io/tansu/functions/asReadable.html) -## Usage +`asReadable` transforms a writable store into a readable one. A readable store is a store without the set and update method. + +This is useful and widely used to compose a custom store: + + - The first parameter is the writable store, + - The second parameter is an object to extend the readable store returned. + +```typescript +import {writable, asReadable} from "@angular/core"; + +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: +```text +Value: 0 +Value: 1 +Value: 0 +(Error thrown !) +``` + +### asWritable + +[api documentation](https://amadeusitgroup.github.io/tansu/functions/asWritable.html) + +`asWritable` is the opposite of an `asReadable`: it transforms a readable store (without a `set` method) into a writable one. + +It's useful when you want to connect your computed store to the original one, or implement a custom `set` method + +```typescript +import {writable, asWritable} from "@angular/core"; + +// JSON transform errors are not managed here, to keep the example clear. +function createLocaleStorage(key: string) { + + function updateFromStorage(e) { + if (e.key === key) { + store$.set(JSON.parse(e.newValue)); + } + } + + const initialValue = JSON.parse(localStorage.getItem(key) ?? '{}'); + const store$ = writable(initialValue, () => { + window.addEventListener('storage', updateFromStorage); + return () => window.removeEventListener('storage', updateFromStorage); + }); + + const api = asWritable(store$, { + set(json: any) { + localStorage.setItem(key, JSON.stringify(json)); + store$.set(json); + }, + reset: () => api.set(initialValue), + }); +} + +const storageValue$ = createLocaleStorage('myKey'); +storageValue$.subscribe((json) => console.log('Value: ', json); +storageValue$.set({a: 1}); +storageValue$.reset(); + +``` +output (localStorage is updated accordingly): +```text +(javascript objects) +Value: {} +Value: {a: 1} +Value: {} +``` + +## 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: @@ -200,7 +527,10 @@ export class App { While being fairly minimal, this example demonstrates most of the Tansu APIs. -Check the [documentation](http://amadeusitgroup.github.io/tansu/) for the complete API and more usage examples. +* works with the standard `async` pipe out of the box +* stores can be registered in the dependency injection (DI) container at any level (module or component injector) +* stores can be used easily with rxjs because they implement the `InteropObservable` interface +* conversely, rxjs observables (or any object implementing the `InteropObservable` interface) can easily be used with Tansu (e.g. in Tansu `computed` or `derived`). ## Contributing to the project