An open API service indexing awesome lists of open source software.

https://github.com/irvrodflo/ngx-state-crafter

Simple and predictable state management for Angular 17+. No actions, no reducers — just signals.
https://github.com/irvrodflo/ngx-state-crafter

Last synced: 5 months ago
JSON representation

Simple and predictable state management for Angular 17+. No actions, no reducers — just signals.

Awesome Lists containing this project

README

          

# ngx-state-crafter

A lightweight, reactive state management library for Angular, built on top of Angular Signals.

**ngx-state-crafter** gives you structure without boilerplate. No actions, no reducers, no decorators — just a simple API that scales from a single component to complex feature states.

---

## Requirements

- Angular 17+

---

## Installation

```bash
npm install @irv-labs/ngx-state-crafter
```

---

## Quick Start

```typescript
import { craftState } from '@irv-labs/ngx-state-crafter';

interface CounterState {
count: number;
label: string;
}

const state = craftState({
count: 0,
label: 'My Counter',
});

// Read values as signals
console.log(state.count()); // 0

// Update state
state.update({ count: 1 });

// Update with a function
state.update({ count: (prev) => prev + 1 });
```

---

## API

### `craftState(initial: T): StateInstance`

Creates a new reactive state instance from an initial object.

```typescript
const state = craftState({ count: 0, name: 'Guest' });
```

---

### `.update(updater)`

Updates one or more properties. Accepts a partial object or a function that receives the current state.

```typescript
// Direct value
state.update({ count: 10 });

// Function updater
state.update({ count: (prev) => prev + 1 });

// Multiple properties at once
state.update({ count: 0, name: 'Alice' });

// Function that returns partial state
state.update((s) => ({ count: s.count + 1 }));
```

---

### `.select(key)`

Returns the signal for a specific property.

```typescript
const count = state.select('count'); // Signal
count(); // read current value
```

---

### `.computed(projector)`

Creates a derived signal from the state. Automatically updates when dependencies change.

```typescript
const doubled = state.computed((s) => s.count * 2);
const label = state.computed((s) => `${s.name}: ${s.count}`);
```

---

### `.effect(fn)`

Runs a side effect whenever the state changes. Returns an `EffectRef`.

```typescript
const ref = state.effect((s) => {
console.log('State changed, count is:', s.count);
});

// Cleanup when done
ref.destroy();
```

---

### `.watch(key, fn)`

Watches a single property and runs a callback when it changes. Returns an `EffectRef`.

```typescript
const ref = state.watch('count', (value) => {
console.log('Count is now:', value);
});
```

---

### `.when(predicate, fn)`

Runs a callback when a condition becomes `true`. Returns an `EffectRef`.

```typescript
const ref = state.when(
(s) => s.count > 10,
() => console.log('Count exceeded 10!'),
);
```

---

### `.merge(key, partial)`

Partially updates a nested object property without replacing the entire value.

```typescript
interface AppState {
user: { name: string; role: string };
}

const state = craftState({
user: { name: 'Guest', role: 'viewer' },
});

// Only updates role, keeps name intact
state.merge('user', { role: 'admin' });
```

---

### `.snapshot()`

Returns the current state as a plain, non-reactive object.

```typescript
const snap = state.snapshot();
console.log(snap); // { count: 5, name: 'Alice' }
```

---

### `.reset()`

Resets the state back to its initial values.

```typescript
state.reset();
```

---

### `.debug(key, label?)`

Logs a property's value to the console whenever it changes. Meant for development only.

```typescript
state.debug('count', 'MyComponent');
// [State Debug: MyComponent] count -> 5
```

---

## Usage in a Component

```typescript
import { Component } from '@angular/core';
import { craftState } from '@irv-labs/ngx-state-crafter';

interface FormState {
email: string;
submitted: boolean;
}

@Component({
selector: 'app-form',
template: `

Submit
`,
})
export class FormComponent {
state = craftState({ email: '', submitted: false });

email = this.state.select('email');
isValid = this.state.computed((s) => s.email.includes('@'));

constructor() {
this.state.when(
(s) => s.submitted,
() => console.log('Form submitted!'),
);
}

setEmail(value: string) {
this.state.update({ email: value });
}

submit() {
this.state.update({ submitted: true });
}
}
```

---

## Usage in a Service

`craftState` works great inside Angular services for shared or feature-level state.

```typescript
import { Injectable } from '@angular/core';
import { craftState } from '@irv-labs/ngx-state-crafter';

interface CartState {
items: { id: number; name: string; price: number }[];
discount: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
private state = craftState({ items: [], discount: 0 });

items = this.state.select('items');
total = this.state.computed((s) => s.items.reduce((sum, i) => sum + i.price, 0) - s.discount);

addItem(item: { id: number; name: string; price: number }) {
this.state.update({ items: (prev) => [...prev, item] });
}

applyDiscount(amount: number) {
this.state.update({ discount: amount });
}

reset() {
this.state.reset();
}
}
```

---

## TypeScript Support

`ngx-state-crafter` is fully typed. All methods infer types from your state interface automatically.

```typescript
interface MyState {
count: number;
name: string;
}

const state = craftState({ count: 0, name: '' });

state.update({ count: 'oops' }); // Type error
state.select('unknown'); // Type error
state.merge('count', {}); // Type error — count is not an object
```

---

## Safety

All callbacks passed to `watch`, `when`, and `effect` are internally wrapped with `untracked`. This means you can safely read or write other signals inside these callbacks without risking infinite reactive loops.

---

## Path Alias (optional)

If you prefer a shorter import, configure a path alias in your `tsconfig.json`:

```json
{
"compilerOptions": {
"paths": {
"@state-crafter": ["./node_modules/@irv-labs/ngx-state-crafter"]
}
}
}
```

Then import from the alias instead:

```typescript
// Before
import { craftState } from '@irv-labs/ngx-state-crafter';

// After
import { craftState } from '@state-crafter';
```

> This is purely a local convenience — it does not affect the published package or your teammates unless they add the same alias to their `tsconfig.json`.

---

## Philosophy

- **No boilerplate** — one function call to get a fully reactive state
- **Signal-native** — built entirely on Angular Signals, no RxJS required
- **Predictable** — safe by default, no surprise loops
- **Minimal API** — learn it in 10 minutes, use it anywhere

---

## License

MIT