Skip to content

Commit

Permalink
feat(signal-slice): add signalSlice (#135)
Browse files Browse the repository at this point in the history
* feat(signal-slice): create entry point

* refactor(connect): export PartialOrValue and Reducer types

* feat(signal-slice): add signal slice implementation

* feat(signal-slice): add tests

* docs(signal-slice): add docs

* docs(signal-slice): add comma

Co-authored-by: Chau Tran <nartc7789@gmail.com>

* docs(signal-slice): improve docs

---------

Co-authored-by: Chau Tran <nartc7789@gmail.com>
  • Loading branch information
joshuamorony and nartc committed Nov 7, 2023
1 parent b17ae1d commit 76fcfad
Show file tree
Hide file tree
Showing 9 changed files with 434 additions and 2 deletions.
136 changes: 136 additions & 0 deletions docs/src/content/docs/utilities/Signals/signal-slice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
---
title: signalSlice
description: ngxtension/signalSlice
---

`signalSlice` is loosely inspired by the `createSlice` API from Redux Toolkit. The general idea is that it allows you to declaratively create a "slice" of state. This state will be available as a **readonly** signal.

The key motivation, and what makes this declarative, is that all the ways for updating this signal are declared upfront with `sources` and `reducers`. It is not possible to imperatively update the state.

## Basic Usage

```ts
import { signalSlice } from 'ngxtension/signal-slice';
```

```ts
private initialState: ChecklistsState = {
checklists: [],
loaded: false,
error: null,
};

state = signalSlice({
initialState: this.initialState,
});
```

The returned `state` object will be a standard **readonly** signal, but it will also have properties attached to it that will be discussed below.

You can access the state as you would with a typical signal:

```ts
this.state().loaded;
```

However, by default `computed` selectors will be created for each top-level property in the initial state:

```ts
this.state.loaded();
```

## Sources

One way to update state is through the use of `sources`. These are intended to be used for "auto sources" — as in, observable streams that will emit automatically like an `http.get()`. Although it will work with a `Subject` that you `next` as well, it is recommended that you use a **reducer** for these imperative style state updates.

You can supply a source like this:

```ts
loadChecklists$ = this.checklistsLoaded$.pipe(map((checklists) => ({ checklists, loaded: true })));

state = signalSlice({
initialState: this.initialState,
sources: [this.loadChecklists$],
});
```

The `source` should be mapped to a partial of the `initialState`. In the example above, when the source emits it will update both the `checklists` and the `loaded` properties in the state signal.

## Reducers and Actions

Another way to update the state is through `reducers` and `actions`. This is good for situations where you need to manually/imperatively trigger some action, and then use the current state in some way in order to calculate the new state.

When you supply a `reducer`, it will automatically create an `action` that you can call. Reducers can be created like this:

```ts
state = signalSlice({
initialState: this.initialState,
reducers: {
add: (state, checklist: AddChecklist) => ({
checklists: [...state.checklists, checklist],
}),
remove: (state, id: RemoveChecklist) => ({
checklists: state.checklists.filter((checklist) => checklist.id !== id),
}),
},
});
```

You can supply a reducer function that has access to the previous state, and whatever payload the action was just called with. Actions are created automatically and can be called like this:

```ts
this.state.add(checklist);
```

It is also possible to have a reducer/action without any payload:

```ts
state = signalSlice({
initialState: this.initialState,
reducers: {
toggleActive: (state) => ({
active: !state.active,
}),
},
});
```

The associated action can then be triggered with:

```ts
this.state.toggleActive();
```

## Action Streams

The source/stream for each action is also exposed on the state object. That means that you can access:

```ts
this.state.add$;
```

Which will allow you to react to the `add` action/reducer being called.

## Selectors

By default, all of the top-level properties from the initial state will be exposed as selectors which are `computed` signals on the state object.

It is also possible to create more selectors simply using `computed` and the values of the signal created by `signalSlice`, however, it is awkward to have some selectors available directly on the state object (our default selectors) and others defined outside of the state object.

It is therefore recommended to define all of your selectors using the `selectors` config of `signalSlice`:

```ts
state = signalSlice({
initialState: this.initialState,
selectors: (state) => ({
loadedAndError: () => state().loaded && state().error,
whatever: () => 'hi',
}),
});
```

This will also make these additional computed values available on the state object:

```ts
this.state.loadedAndError();
```
6 changes: 4 additions & 2 deletions libs/ngxtension/connect/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { assertInjector } from 'ngxtension/assert-injector';
import { Subscription, isObservable, type Observable } from 'rxjs';

type PartialOrValue<TValue> = TValue extends object ? Partial<TValue> : TValue;
type Reducer<TValue, TNext> = (
export type PartialOrValue<TValue> = TValue extends object
? Partial<TValue>
: TValue;
export type Reducer<TValue, TNext> = (
previous: TValue,
next: TNext
) => PartialOrValue<TValue>;
Expand Down
3 changes: 3 additions & 0 deletions libs/ngxtension/signal-slice/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ngxtension/signal-slice

Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/signal-slice`.
5 changes: 5 additions & 0 deletions libs/ngxtension/signal-slice/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
33 changes: 33 additions & 0 deletions libs/ngxtension/signal-slice/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "ngxtension/signal-slice",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/ngxtension/signal-slice/src",
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/ngxtension/jest.config.ts",
"testPathPattern": ["signal-slice"],
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"libs/ngxtension/signal-slice/**/*.ts",
"libs/ngxtension/signal-slice/**/*.html"
]
}
}
}
}
1 change: 1 addition & 0 deletions libs/ngxtension/signal-slice/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './signal-slice';
125 changes: 125 additions & 0 deletions libs/ngxtension/signal-slice/src/signal-slice.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import { SignalSlice, signalSlice } from './signal-slice';

describe(signalSlice.name, () => {
const initialState = {
user: {
firstName: 'josh',
lastName: 'morony',
},
age: 30,
likes: ['angular', 'typescript'],
};

describe('initialState', () => {
let state: SignalSlice<typeof initialState, any, any>;

beforeEach(() => {
TestBed.runInInjectionContext(() => {
state = signalSlice({
initialState,
});
});
});

it('should create a signal of initialState', () => {
expect(state().user.firstName).toEqual(initialState.user.firstName);
});

it('should create default selectors', () => {
expect(state.age()).toEqual(initialState.age);
});
});

describe('sources', () => {
const testSource$ = new Subject<Partial<typeof initialState>>();
const testSource2$ = new Subject<Partial<typeof initialState>>();

let state: SignalSlice<typeof initialState, any, any>;

beforeEach(() => {
TestBed.runInInjectionContext(() => {
state = signalSlice({
initialState,
sources: [testSource$],
});
});
});

it('should be initial value initially', () => {
expect(state().user.firstName).toEqual(initialState.user.firstName);
});

it('should update with value from source after emission', () => {
const testUpdate = { user: { firstName: 'chau', lastName: 'tran' } };
testSource$.next(testUpdate);
expect(state().user.firstName).toEqual(testUpdate.user.firstName);
});

it('should work with multiple sources', () => {
TestBed.runInInjectionContext(() => {
state = signalSlice({
initialState,
sources: [testSource$, testSource2$],
});
});

const testUpdate = { user: { firstName: 'chau', lastName: 'tran' } };
const testUpdate2 = { age: 20 };
testSource$.next(testUpdate);
testSource2$.next(testUpdate2);

expect(state().user.firstName).toEqual(testUpdate.user.firstName);
expect(state().age).toEqual(testUpdate2.age);
});
});

describe('reducers', () => {
it('should create action that updates signal', () => {
TestBed.runInInjectionContext(() => {
const state = signalSlice({
initialState,
reducers: {
increaseAge: (state, amount: number) => ({
age: state.age + amount,
}),
},
});

const amount = 1;
state.increaseAge(amount);
expect(state().age).toEqual(initialState.age + amount);
});
});

it('should create action stream for reducer', () => {
TestBed.runInInjectionContext(() => {
const state = signalSlice({
initialState,
reducers: {
increaseAge: (state, amount: number) => ({
age: state.age + amount,
}),
},
});
expect(state.increaseAge$).toBeDefined();
});
});
});

describe('selectors', () => {
it('should add custom selectors to state object', () => {
TestBed.runInInjectionContext(() => {
const state = signalSlice({
initialState,
selectors: (state) => ({
doubleAge: () => state().age * 2,
}),
});

expect(state.doubleAge()).toEqual(state().age * 2);
});
});
});
});
Loading

0 comments on commit 76fcfad

Please sign in to comment.