npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

flurryx

v0.8.0

Published

Signal-first reactive state management for Angular

Readme

Signal-first reactive state management for Angular.

flurryx bridges the gap between RxJS async operations and Angular signals. Define a store, pipe your HTTP calls through an operator, read signals in your templates. No actions, no reducers, no effects boilerplate.

// Store — declare your slots with a single interface
interface ProductStoreConfig {
  LIST: Product[];
  DETAIL: Product;
}
export const ProductStore = Store.for<ProductStoreConfig>().build();

// Facade
@Injectable()
export class ProductFacade {
  @SkipIfCached("LIST", (i) => i.store)
  @Loading("LIST", (i) => i.store)
  loadProducts() {
    this.http
      .get<Product[]>("/api/products")
      .pipe(syncToStore(this.store, "LIST"))
      .subscribe();
  }

  // Read signals from the facade
  getProducts() {
    return this.store.get("LIST");
  }
}

// Component — read the facade signal once, use it in the template
@Component({
  selector: "app-product-list",
  template: `
    @if (productsState().isLoading) {
    <spinner />
    } @for (product of productsState().data; track product.id) {
    <product-card [product]="product" />
    }
  `,
})
export class ProductListComponent {
  private readonly facade = inject(ProductFacade);
  readonly productsState = this.facade.getProducts();
}

No async pipe. No subscribe in templates. No manual unsubscription.


Table of Contents


Why flurryx?

Angular signals are great for synchronous reactivity, but real applications still need RxJS for HTTP calls, WebSockets, and other async sources. The space between "I fired a request" and "my template shows the result" is where complexity piles up:

| Problem | Without flurryx | With flurryx | | ------------------ | -------------------------------------------------- | ---------------------------------------------- | | Loading spinners | Manual boolean flags, race conditions | store.get(key)().isLoading | | Error handling | Scattered catchError blocks, inconsistent shapes | Normalized { code, message }[] on every slot | | Caching | Custom shareReplay / BehaviorSubject wiring | @SkipIfCached decorator, one line | | Duplicate requests | Manual distinctUntilChanged, inflight tracking | @SkipIfCached deduplicates while loading | | Keyed resources | Separate state per ID, boilerplate explosion | KeyedResourceData with per-key loading/error |

flurryx gives you one fluent builder, two RxJS operators, and two decorators. That's the entire API.


How to Install

npm install flurryx

That's it. The flurryx package re-exports everything from the three internal packages (@flurryx/core, @flurryx/store, @flurryx/rx), so every import comes from a single place:

import { Store, syncToStore, SkipIfCached, Loading } from "flurryx";
import type { ResourceState, KeyedResourceData } from "flurryx";

For the Angular HTTP error normalizer (optional — keeps @angular/common/http out of your bundle unless you need it):

import { httpErrorNormalizer } from "flurryx/http";

Peer dependencies (you likely already have these):

| Peer | Version | | ----------------- | --------------------------------- | | @angular/core | >=17 | | rxjs | >=7 | | @angular/common | optional, only for flurryx/http |

Note: Your tsconfig.json must include "experimentalDecorators": true if you use @SkipIfCached or @Loading.

If you prefer granular control over your dependency tree, the internal packages are published independently:

@flurryx/core   →  Types, models, utilities             (0 runtime deps)
@flurryx/store  →  BaseStore with Angular signals        (peer: @angular/core >=17)
@flurryx/rx     →  RxJS operators + decorators           (peer: rxjs >=7, @angular/core >=17)
npm install @flurryx/core @flurryx/store @flurryx/rx
@flurryx/core  ←── @flurryx/store
                        ↑
                   @flurryx/rx

Getting Started

Step 1 — Define your store

Define a TypeScript interface mapping slot names to their data types, then pass it to the Store builder:

import { Store } from "flurryx";

interface ProductStoreConfig {
  LIST: Product[];
  DETAIL: Product;
}

export const ProductStore = Store.for<ProductStoreConfig>().build();

That's it. The interface is type-only — zero runtime cost. The builder returns an InjectionToken with providedIn: 'root'. Every call to store.get('LIST') returns Signal<ResourceState<Product[]>>, and invalid keys or mismatched types are caught at compile time.

Step 2 — Create a facade

The facade owns the store and exposes signals + data-fetching methods.

import { Injectable, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { syncToStore, SkipIfCached, Loading } from "flurryx";

@Injectable()
export class ProductFacade {
  private readonly http = inject(HttpClient);
  readonly store = inject(ProductStore);

  getProducts() {
    return this.store.get("LIST");
  }

  getProductDetail() {
    return this.store.get("DETAIL");
  }

  @SkipIfCached("LIST", (i: ProductFacade) => i.store)
  @Loading("LIST", (i: ProductFacade) => i.store)
  loadProducts() {
    this.http
      .get<Product[]>("/api/products")
      .pipe(syncToStore(this.store, "LIST"))
      .subscribe();
  }

  @Loading("DETAIL", (i: ProductFacade) => i.store)
  loadProduct(id: string) {
    this.http
      .get<Product>(`/api/products/${id}`)
      .pipe(syncToStore(this.store, "DETAIL"))
      .subscribe();
  }
}

Step 3 — Use in your component

@Component({
  template: `
    @if (productsState().isLoading) {
    <spinner />
    } @if (productsState().status === 'Success') { @for (product of
    productsState().data; track product.id) {
    <product-card [product]="product" />
    } } @if (productsState().status === 'Error') {
    <error-banner [errors]="productsState().errors" />
    }
  `,
})
export class ProductListComponent {
  private readonly facade = inject(ProductFacade);
  readonly productsState = this.facade.getProducts();

  constructor() {
    this.facade.loadProducts();
  }
}

The component reads signals directly. No async pipe, no subscribe, no OnDestroy cleanup.


How to Use

ResourceState

The fundamental unit of state. Every store slot holds one:

interface ResourceState<T> {
  isLoading?: boolean;
  data?: T;
  status?: "Success" | "Error";
  errors?: Array<{ code: string; message: string }>;
}

A slot starts as { data: undefined, isLoading: false, status: undefined, errors: undefined } and transitions through a predictable lifecycle:

  ┌─────────┐   startLoading   ┌───────────┐   next    ┌─────────┐
  │  IDLE   │ ───────────────→ │  LOADING  │ ────────→ │ SUCCESS │
  └─────────┘                  └───────────┘           └─────────┘
                                     │
                                     │ error
                                     ▼
                               ┌───────────┐
                               │   ERROR   │
                               └───────────┘

Store API

The Store builder creates a store backed by Signal<ResourceState> per slot. Three creation styles are available:

// 1. Interface-based (recommended) — type-safe with zero boilerplate
interface MyStoreConfig {
  USERS: User[];
  SELECTED: User;
}
export const MyStore = Store.for<MyStoreConfig>().build();

// 2. Fluent chaining — inline slot definitions
export const MyStore = Store
  .resource('USERS').as<User[]>()
  .resource('SELECTED').as<User>()
  .build();

// 3. Enum-constrained — validates keys against a runtime enum
export const MyStore = Store.for(MyStoreEnum)
  .resource('USERS').as<User[]>()
  .resource('SELECTED').as<User>()
  .build();

Once injected, the store exposes these methods:

| Method | Description | | ------------------------- | ------------------------------------------------------------------------------------- | | get(key) | Returns the Signal for a slot | | update(key, partial) | Merges partial state (immutable spread) | | clear(key) | Resets a slot to its initial empty state | | clearAll() | Resets every slot | | startLoading(key) | Sets isLoading: true, clears status and errors | | stopLoading(key) | Sets isLoading: false, clears status and errors | | onUpdate(key, callback) | Registers a listener fired after update or clear. Returns an unsubscribe function |

Keyed methods (for KeyedResourceData slots):

| Method | Description | | ------------------------------------------ | -------------------------------------- | | updateKeyedOne(key, resourceKey, entity) | Merges one entity into a keyed slot | | clearKeyedOne(key, resourceKey) | Removes one entity from a keyed slot | | startKeyedLoading(key, resourceKey) | Sets loading for a single resource key |

Update hooks are stored in a WeakMap keyed by store instance, so garbage collection works naturally across multiple store lifetimes.

Read-only signals

get(key) returns a read-only Signal, not a WritableSignal. Consumers can read state but cannot mutate it directly — all writes must go through the store's own methods (update, clear, startLoading, …). This enforces strict encapsulation: the store is the single owner of its state, and external code can only observe it.

Store Creation Styles

Interface-based: Store.for<Config>().build()

The recommended approach. Define a TypeScript interface where keys are slot names and values are the data types:

import { Store } from "flurryx";

interface ChatStoreConfig {
  SESSIONS: ChatSession[];
  CURRENT_SESSION: ChatSession;
  MESSAGES: ChatMessage[];
}

export const ChatStore = Store.for<ChatStoreConfig>().build();

The generic argument is type-only — there is no runtime enum or config object. Under the hood, the store lazily creates signals on first access, so un-accessed keys have zero overhead.

Type safety is fully enforced:

const store = inject(ChatStore);

store.get('SESSIONS');                          // Signal<ResourceState<ChatSession[]>>
store.update('SESSIONS', { data: [session] });  // ✅ type-checked
store.update('SESSIONS', { data: 42 });         // ❌ TS error — number is not ChatSession[]
store.get('INVALID');                           // ❌ TS error — key does not exist

Fluent chaining: Store.resource().as<T>().build()

Define slots inline without a separate interface:

export const ChatStore = Store
  .resource('SESSIONS').as<ChatSession[]>()
  .resource('CURRENT_SESSION').as<ChatSession>()
  .resource('MESSAGES').as<ChatMessage[]>()
  .build();

Enum-constrained: Store.for(enum).resource().as<T>().build()

When you have a runtime enum (e.g. shared with backend code), pass it to .for() to ensure every key is accounted for:

const ChatStoreEnum = {
  SESSIONS: 'SESSIONS',
  CURRENT_SESSION: 'CURRENT_SESSION',
  MESSAGES: 'MESSAGES',
} as const;

export const ChatStore = Store.for(ChatStoreEnum)
  .resource('SESSIONS').as<ChatSession[]>()
  .resource('CURRENT_SESSION').as<ChatSession>()
  .resource('MESSAGES').as<ChatMessage[]>()
  .build();

The builder only allows keys from the enum, and .build() is only available once all keys have been defined.

syncToStore

RxJS pipeable operator that bridges an Observable to a store slot.

this.http
  .get<Product[]>("/api/products")
  .pipe(syncToStore(this.store, 'LIST'))
  .subscribe();

What it does:

  • On next — writes { data, isLoading: false, status: 'Success', errors: undefined }
  • On error — writes { data: undefined, isLoading: false, status: 'Error', errors: [...] }
  • Completes after first emission by default (take(1))

Options:

syncToStore(store, key, {
  completeOnFirstEmission: true, // default: true — applies take(1)
  callbackAfterComplete: () => {}, // runs in finalize()
  errorNormalizer: myNormalizer, // default: defaultErrorNormalizer
});

syncToKeyedStore

Same pattern, but targets a specific resource key within a KeyedResourceData slot:

this.http
  .get<Invoice>(`/api/invoices/${id}`)
  .pipe(syncToKeyedStore(this.store, 'ITEMS', id))
  .subscribe();

Only the targeted resource key is updated. Other keys in the same slot are untouched.

mapResponse — transform the API response before writing to the store:

syncToKeyedStore(this.store, 'ITEMS', id, {
  mapResponse: (response) => response.data,
});

@SkipIfCached

Method decorator that skips execution when the store already has valid data.

@SkipIfCached('LIST', (i) => i.store)
loadProducts() { /* only runs when cache is stale */ }

Cache hit (method skipped) when:

  • status === 'Success' or isLoading === true
  • Timeout has not expired (default: 5 minutes)
  • Method arguments match (compared via JSON.stringify)

Cache miss (method executes) when:

  • Initial state (no status, not loading)
  • status === 'Error' (errors are never cached)
  • Timeout expired
  • Arguments changed

Parameters:

@SkipIfCached(
  'LIST',                       // which store slot to check
  (instance) => instance.store, // how to get the store from `this`
  returnObservable?,            // false (default): void methods; true: returns Observable
  timeoutMs?                    // default: 300_000 (5 min). Use CACHE_NO_TIMEOUT for infinite
)

Observable mode (returnObservable: true):

  • Cache hit returns of(cachedData) or coalesces onto the in-flight Observable via shareReplay
  • Cache miss executes the method and wraps the result with inflight tracking

Keyed resources: When the first argument is a string | number and the store data is a KeyedResourceData, cache entries are tracked per resource key automatically.

@Loading

Method decorator that calls store.startLoading(key) before the original method executes.

@Loading('LIST', (i) => i.store)
loadProducts() { /* store.isLoading is already true when this runs */ }

Keyed detection: If the first argument is a string | number and the store has startKeyedLoading, it calls that instead for per-key loading state.

Compose both decorators for the common pattern:

@SkipIfCached('LIST', (i) => i.store)
@Loading('LIST', (i) => i.store)
loadProducts() {
  this.http.get('/api/products')
    .pipe(syncToStore(this.store, 'LIST'))
    .subscribe();
}

Order matters: @SkipIfCached is outermost so it can short-circuit before @Loading sets the loading flag.

Error Normalization

Operators accept a pluggable errorNormalizer instead of coupling to Angular's HttpErrorResponse:

type ErrorNormalizer = (error: unknown) => ResourceErrors;

defaultErrorNormalizer (used by default) handles:

  1. { error: { errors: [...] } } — extracts the nested array
  2. { status: number, message: string } — wraps into [{ code, message }]
  3. Error instances — wraps error.message
  4. Anything else — [{ code: 'UNKNOWN', message: String(error) }]

httpErrorNormalizer — for Angular's HttpErrorResponse, available from a separate entry point to keep @angular/common/http out of your bundle unless you need it:

import { httpErrorNormalizer } from "flurryx/http";

this.http
  .get("/api/data")
  .pipe(
    syncToStore(this.store, 'DATA', {
      errorNormalizer: httpErrorNormalizer,
    }),
  )
  .subscribe();

Custom normalizer — implement your own for any backend error shape:

const myNormalizer: ErrorNormalizer = (error) => {
  const typed = error as MyBackendError;
  return typed.details.map((d) => ({
    code: d.errorCode,
    message: d.userMessage,
  }));
};

Constants

import { CACHE_NO_TIMEOUT, DEFAULT_CACHE_TTL_MS } from "flurryx";

CACHE_NO_TIMEOUT; // Infinity — cache never expires
DEFAULT_CACHE_TTL_MS; // 300_000 (5 minutes)

Keyed Resources

For data indexed by ID (user profiles, invoices, config entries), use KeyedResourceData:

interface KeyedResourceData<TKey extends string | number, TValue> {
  entities: Partial<Record<TKey, TValue>>;
  isLoading: Partial<Record<TKey, boolean>>;
  status: Partial<Record<TKey, ResourceStatus>>;
  errors: Partial<Record<TKey, ResourceErrors>>;
}

Each resource key gets independent loading, status, and error tracking. The top-level ResourceState.isLoading reflects whether any key is loading.

Full example:

// Store
import { Store } from "flurryx";
import type { KeyedResourceData } from "flurryx";

export const InvoiceStore = Store
  .resource('ITEMS').as<KeyedResourceData<string, Invoice>>()
  .build();

// Facade
@Injectable({ providedIn: "root" })
export class InvoiceFacade {
  private readonly http = inject(HttpClient);
  readonly store = inject(InvoiceStore);
  readonly items = this.store.get('ITEMS');

  @SkipIfCached('ITEMS', (i: InvoiceFacade) => i.store)
  @Loading('ITEMS', (i: InvoiceFacade) => i.store)
  loadInvoice(id: string) {
    this.http
      .get<Invoice>(`/api/invoices/${id}`)
      .pipe(syncToKeyedStore(this.store, 'ITEMS', id))
      .subscribe();
  }
}

// Component
const data = this.facade.items().data; // KeyedResourceData
const invoice = data?.entities["inv-123"]; // Invoice | undefined
const loading = data?.isLoading["inv-123"]; // boolean | undefined
const errors = data?.errors["inv-123"]; // ResourceErrors | undefined

Utilities:

import {
  createKeyedResourceData, // factory — returns empty { entities: {}, isLoading: {}, ... }
  isKeyedResourceData, // type guard
  isAnyKeyLoading, // (loading: Record) => boolean
} from "flurryx";

Store Mirroring

When building session or aggregation stores that combine state from multiple feature stores, you typically need onUpdate listeners, cleanup arrays, and DestroyRef wiring. The mirrorKey and collectKeyed utilities reduce that to a single call.

+--------------------+                    +--------------------+
| Feature Store A    |                    |                    |
| (CUSTOMERS)        |-- mirrorKey ------>|                    |
+--------------------+                    |                    |
                                          |  Session Store     |
+--------------------+                    |  (aggregated)      |
| Feature Store B    |                    |                    |
| (ORDERS)           |-- mirrorKey ------>|  CUSTOMERS      +  |
+--------------------+                    |  ORDERS         +  |
                                          |  CUSTOMER_CACHE +  |
+--------------------+                    |  ORDER_CACHE    +  |
| Feature Store C    |                    |                    |
| (CUSTOMER_DETAIL)  |-- collectKeyed --->|                    |
+--------------------+                    |                    |
                                          |                    |
+--------------------+                    |                    |
| Feature Store D    |                    |                    |
| (ORDER_DETAIL)     |-- mirrorKeyed --->|                    |
+--------------------+                    +--------------------+
import { Store, mirrorKey, collectKeyed } from "flurryx";

Builder .mirror()

The simplest way to set up mirroring is directly in the store builder. Chain .mirror() to declare which source stores to mirror from — the wiring happens automatically when Angular creates the store.

// Feature stores
interface CustomerStoreConfig {
  CUSTOMERS: Customer[];
}
export const CustomerStore = Store.for<CustomerStoreConfig>().build();

interface OrderStoreConfig {
  ORDERS: Order[];
}
export const OrderStore = Store.for<OrderStoreConfig>().build();

Interface-based builder (recommended):

interface SessionStoreConfig {
  CUSTOMERS: Customer[];
  ORDERS: Order[];
}

export const SessionStore = Store.for<SessionStoreConfig>()
  .mirror(CustomerStore, 'CUSTOMERS')
  .mirror(OrderStore, 'ORDERS')
  .build();

Fluent chaining:

export const SessionStore = Store
  .resource('CUSTOMERS').as<Customer[]>()
  .resource('ORDERS').as<Order[]>()
  .mirror(CustomerStore, 'CUSTOMERS')
  .mirror(OrderStore, 'ORDERS')
  .build();

Enum-constrained:

const SessionEnum = { CUSTOMERS: 'CUSTOMERS', ORDERS: 'ORDERS' } as const;

export const SessionStore = Store.for(SessionEnum)
  .resource('CUSTOMERS').as<Customer[]>()
  .resource('ORDERS').as<Order[]>()
  .mirror(CustomerStore, 'CUSTOMERS')
  .mirror(OrderStore, 'ORDERS')
  .build();

Different source and target keys:

export const SessionStore = Store.for<{ ARTICLES: Item[] }>()
  .mirror(ItemStore, 'ITEMS', 'ARTICLES')
  .build();

The builder calls inject() under the hood, so source stores are resolved through Angular's DI. Everything — data, loading, status, errors — is mirrored automatically. No manual cleanup needed; the mirrors live as long as the store.

Builder .mirrorSelf()

Use .mirrorSelf() when one slot in a store should mirror another slot in the same store. It is useful for aliases, local snapshots, or secondary slots that should stay in sync with a primary slot without wiring onUpdate manually.

interface SessionStoreConfig {
  CUSTOMER_DETAILS: Customer;
  CUSTOMER_SNAPSHOT: Customer;
}

export const SessionStore = Store.for<SessionStoreConfig>()
  .mirrorSelf('CUSTOMER_DETAILS', 'CUSTOMER_SNAPSHOT')
  .build();

It mirrors the full resource state one way — data, isLoading, status, and errors all flow from the source key to the target key. The target key must be different from the source key.

Because it listens to updates on the built store itself, .mirrorSelf() also reacts when the source key is updated by another mirror:

interface CustomerStoreConfig {
  CUSTOMERS: Customer[];
}

interface SessionStoreConfig {
  CUSTOMERS: Customer[];
  CUSTOMER_COPY: Customer[];
}

export const CustomerStore = Store.for<CustomerStoreConfig>().build();

export const SessionStore = Store.for<SessionStoreConfig>()
  .mirror(CustomerStore, 'CUSTOMERS')
  .mirrorSelf('CUSTOMERS', 'CUSTOMER_COPY')
  .build();

.mirrorSelf() is available on all builder styles. For fluent builders, declare both slots first, then chain .mirrorSelf(sourceKey, targetKey) before .build().

Builder .mirrorKeyed()

When the source store holds a single-entity slot (e.g. CUSTOMER_DETAILS: Customer) and you want to accumulate those fetches into a KeyedResourceData cache on the target, use .mirrorKeyed(). It is the builder equivalent of collectKeyed.

// Feature store — fetches one customer at a time
interface CustomerStoreConfig {
  CUSTOMERS: Customer[];
  CUSTOMER_DETAILS: Customer;
}
export const CustomerStore = Store.for<CustomerStoreConfig>().build();

Interface-based builder (recommended):

interface SessionStoreConfig {
  CUSTOMERS: Customer[];
  CUSTOMER_CACHE: KeyedResourceData<string, Customer>;
}

export const SessionStore = Store.for<SessionStoreConfig>()
  .mirror(CustomerStore, 'CUSTOMERS')
  .mirrorKeyed(CustomerStore, 'CUSTOMER_DETAILS', {
    extractId: (data) => data?.id,
  }, 'CUSTOMER_CACHE')
  .build();

Fluent chaining:

export const SessionStore = Store
  .resource('CUSTOMERS').as<Customer[]>()
  .resource('CUSTOMER_CACHE').as<KeyedResourceData<string, Customer>>()
  .mirror(CustomerStore, 'CUSTOMERS')
  .mirrorKeyed(CustomerStore, 'CUSTOMER_DETAILS', {
    extractId: (data) => data?.id,
  }, 'CUSTOMER_CACHE')
  .build();

Enum-constrained:

const SessionEnum = { CUSTOMERS: 'CUSTOMERS', CUSTOMER_CACHE: 'CUSTOMER_CACHE' } as const;

export const SessionStore = Store.for(SessionEnum)
  .resource('CUSTOMERS').as<Customer[]>()
  .resource('CUSTOMER_CACHE').as<KeyedResourceData<string, Customer>>()
  .mirror(CustomerStore, 'CUSTOMERS')
  .mirrorKeyed(CustomerStore, 'CUSTOMER_DETAILS', {
    extractId: (data) => data?.id,
  }, 'CUSTOMER_CACHE')
  .build();

Same source and target key — when the key names match, the last argument can be omitted:

export const SessionStore = Store.for<{
  CUSTOMER_DETAILS: KeyedResourceData<string, Customer>;
}>()
  .mirrorKeyed(CustomerStore, 'CUSTOMER_DETAILS', {
    extractId: (data) => data?.id,
  })
  .build();

Each entity fetched through the source slot is accumulated by ID into the target's KeyedResourceData. Loading, status, and errors are tracked per entity. When the source is cleared, the corresponding entity is removed from the cache.

mirrorKey

Mirrors a resource key from one store to another. When the source updates, the target is updated with the same state.

+------------------+--------------------------------+------------------+
| CustomerStore    |          mirrorKey             | SessionStore     |
|                  |                                |                  |
| CUSTOMERS -------|--- onUpdate --> update ------->| CUSTOMERS        |
|                  |   (same key or different)      |                  |
| { data,          |                                | { data,          |
|   status,        |                                |   status,        |
|   isLoading }    |                                |   isLoading }    |
+------------------+--------------------------------+------------------+

source.update('CUSTOMERS', { data: [...], status: 'Success' })
     |
     '--> target is automatically updated with the same state

You wire it once. Every future update — data, loading, errors — flows automatically. Call the cleanup function or use destroyRef to stop.

// Same key on both stores (default)
mirrorKey(customersStore, 'CUSTOMERS', sessionStore);

// Different keys
mirrorKey(customersStore, 'ITEMS', sessionStore, 'ARTICLES');

// Manual cleanup
const cleanup = mirrorKey(customersStore, 'CUSTOMERS', sessionStore);
cleanup(); // stop mirroring

// Auto-cleanup with Angular DestroyRef
mirrorKey(customersStore, 'CUSTOMERS', sessionStore, { destroyRef });
mirrorKey(customersStore, 'ITEMS', sessionStore, 'ARTICLES', { destroyRef });

Full example — session store that aggregates feature stores:

For simple aggregation, prefer the builder .mirror() approach. Use mirrorKey when you need imperative control — e.g. conditional mirroring, late setup, or DestroyRef-based cleanup:

@Injectable({ providedIn: 'root' })
export class SessionStore {
  private readonly customerStore = inject(CustomerStore);
  private readonly orderStore = inject(OrderStore);
  private readonly store = inject(Store.for<SessionStoreConfig>().build());
  private readonly destroyRef = inject(DestroyRef);

  readonly customers = this.store.get('CUSTOMERS');
  readonly orders = this.store.get('ORDERS');

  constructor() {
    mirrorKey(this.customerStore, 'CUSTOMERS', this.store, { destroyRef: this.destroyRef });
    mirrorKey(this.orderStore, 'ORDERS', this.store, { destroyRef: this.destroyRef });
  }
}

Everything — loading flags, data, status, errors — is mirrored automatically. No manual onUpdate + cleanup boilerplate.

collectKeyed

Accumulates single-entity fetches into a KeyedResourceData cache on a target store. Each time the source emits a successful entity, it is merged into the target's keyed map by a user-provided extractId function.

+--------------------+-----------------+--------------------------+
| CustomerStore      |  collectKeyed   | SessionStore             |
|                    |                 |                          |
| CUSTOMER_DETAILS   | extractId(data) | CUSTOMER_CACHE           |
| (one at a time)    | finds the key   | (KeyedResourceData)      |
+--------+-----------+-----------------+                          |
         |                             | entities:                |
         |  fetch("c1") -> Success     |   c1: { id, name }       |
         |  fetch("c2") -> Success     |   c2: { id, name }       |
         |  fetch("c3") -> Error       |                          |
         |                             | isLoading:               |
         |  clear() -> removes last    |   c1: false              |
         |              entity         |   c2: false              |
         |                             |                          |
         '---- accumulates ----------->| status:                  |
                                       |   c1: 'Success'          |
                                       |   c2: 'Success'          |
                                       |   c3: 'Error'            |
                                       |                          |
                                       | errors:                  |
                                       |   c3: [{ code, msg }]    |
                                       +--------------------------+

Each entity is tracked independently — its own loading flag, status, and errors. The source store fetches one entity at a time; collectKeyed builds up the full cache on the target.

// Same key on both stores
collectKeyed(customerStore, 'CUSTOMER_DETAILS', sessionStore, {
  extractId: (data) => data?.id,
  destroyRef,
});

// Different keys
collectKeyed(customerStore, 'CUSTOMER_DETAILS', sessionStore, 'CUSTOMER_CACHE', {
  extractId: (data) => data?.id,
  destroyRef,
});

What it does on each source update:

| Source state | Action | |---|---| | status: 'Success' + valid ID | Merges entity into target's keyed data | | status: 'Error' + valid ID | Records per-key error and status | | isLoading: true + valid ID | Sets per-key loading flag | | Data cleared (e.g. source.clear()) | Removes previous entity from target |

Full example — collect individual customer lookups into a cache:

// Feature store — fetches one customer at a time
interface CustomerStoreConfig {
  CUSTOMER_DETAILS: Customer;
}
export const CustomerStore = Store.for<CustomerStoreConfig>().build();

// Session store — accumulates all fetched customers
interface SessionStoreConfig {
  CUSTOMER_CACHE: KeyedResourceData<string, Customer>;
}

@Injectable({ providedIn: 'root' })
export class SessionStore {
  private readonly customerStore = inject(CustomerStore);
  private readonly store = inject(Store.for<SessionStoreConfig>().build());
  private readonly destroyRef = inject(DestroyRef);

  readonly customerCache = this.store.get('CUSTOMER_CACHE');

  constructor() {
    collectKeyed(this.customerStore, 'CUSTOMER_DETAILS', this.store, 'CUSTOMER_CACHE', {
      extractId: (data) => data?.id,
      destroyRef: this.destroyRef,
    });
  }

  // After loading customers "c1" and "c2", the cache contains:
  // {
  //   entities: { c1: Customer, c2: Customer },
  //   isLoading: { c1: false, c2: false },
  //   status: { c1: 'Success', c2: 'Success' },
  //   errors: {}
  // }
}

Design Decisions

Why signals instead of BehaviorSubject? Angular signals are synchronous, glitch-free, and template-native. They eliminate the need for async pipe, shareReplay, and manual unsubscription in components. RxJS stays in the service/facade layer where it belongs — for async operations.

Why not NgRx / NGXS / Elf? Those are general-purpose state management libraries with actions, reducers, and effects. flurryx solves a narrower problem: the loading/data/error lifecycle of API calls. If your needs are "fetch data, show loading, handle errors, cache results", flurryx is the right size.

Why Partial<Record> instead of Map for keyed data? Plain objects work with Angular's change detection and signals out of the box. Maps require additional serialization. This also means zero migration friction.

Why experimentalDecorators? The decorators use TypeScript's legacy decorator syntax. TC39 decorator migration is planned for a future release.

Why tsup instead of ng-packagr? flurryx contains no Angular components, templates, or directives — just TypeScript that calls signal() at runtime. Angular Package Format (APF) adds complexity without benefit here. tsup produces ESM + CJS + .d.ts in milliseconds.


Contributing

git clone https://github.com/fmflurry/flurryx.git
cd flurryx
npm install
npm run build
npm run test

| Command | What it does | | ----------------------- | ------------------------------------------------ | | npm run build | Builds all packages (ESM + CJS + .d.ts) via tsup | | npm run test | Runs vitest across all packages | | npm run test:coverage | Tests with v8 coverage report | | npm run typecheck | tsc --noEmit across all packages |

Monorepo managed with npm workspaces. Versioning with changesets.


License

MIT