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

react-native-mobile-mvvm

v0.8.5

Published

MVVM Architecture Standard for React Native — Compose/Flutter DX (ViewModel, StateFlow, DI)

Readme

react-native-mobile-mvvm

MVVM Architecture for React Native — Compose & Flutter DX

Bring the developer experience of Jetpack Compose, Flutter BLoC, and SwiftUI into React Native.
Clean lifecycle management, reactive state, and dependency injection — all in one package.

npm version npm downloads CI License: MIT TypeScript


Why?

React Native lacks an opinionated architecture standard. Teams coming from Android or Flutter are forced to learn a completely different mental model — hooks, context, and global stores — from scratch.

This package solves that by providing 8 core modules that map directly to patterns you already know:

| This Package | Android/Compose | Flutter | SwiftUI | |---|---|---|---| | ViewModel | ViewModel + viewModelScope | ChangeNotifier + dispose() | ObservableObject | | StateFlow<T> | MutableStateFlow<T> | BehaviorSubject | @Published | | ReadOnlyStateFlow<T> | StateFlow<T> (read-only) | ValueStream (rxdart) | @Published (get only) | | EventFlow<T> | SharedFlow(replay=0) / Channel | StreamController one-shot | PassthroughSubject | | ComputedStateFlow | derivedStateOf {} | combineLatest() (RxDart) | combine() / Combine | | UiState<T> | sealed class UiState | ConnectionState / BLoC states | Enum-driven state | | useViewModel() | hiltViewModel() | context.watch<T>() | @StateObject | | useStream() | collectAsStateWithLifecycle() | StreamBuilder | .sink + @Published | | useEvent() | LaunchedEffect + SharedFlow | BlocListener | .onReceive | | useInit() | LaunchedEffect(Unit) | initState() | .task { } | | useLifecycle() | DisposableEffect(Unit) | StatefulWidget + dispose() | .onAppear + .onDisappear | | useUiState() | when (state) { is Loading } | AsyncSnapshot fields | switch state { } | | ViewModelScope | Nav graph scope | MultiProvider / InheritedWidget | @EnvironmentObject | | useScopedViewModel() | hiltViewModel(navBackStackEntry) | context.read<T>() at scope level | @EnvironmentObject | | @Injectable | @HiltViewModel | @injectable (GetIt) | — |

StateFlow vs EventFlow — When to use which

| | StateFlow<T> | EventFlow<T> | |---|---|---| | Purpose | UI state | One-shot side effects | | Replay | Yes — new subscribers get the last value | No — emit once, done | | Examples | isLoading, user, formData | Navigation, snackbar, dialog | | Hook | useStream() | useEvent() |


Installation

# npm
npm install react-native-mobile-mvvm rxjs

# yarn
yarn add react-native-mobile-mvvm rxjs

# pnpm
pnpm add react-native-mobile-mvvm rxjs

With Dependency Injection (optional)

If you want to use @Injectable, @Inject, and configureDI():

npm install tsyringe reflect-metadata

Then enable decorator support in your tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Quick Start

1. Define a ViewModel

// CounterViewModel.ts
import { ViewModel, StateFlow, ReadOnlyStateFlow } from 'react-native-mobile-mvvm';

export class CounterViewModel extends ViewModel {
  private _count = new StateFlow<number>(0);

  // ✅ Expose a read-only version — UI can't mutate, but can read .value
  public readonly count$: ReadOnlyStateFlow<number> = this._count;

  increment() {
    this._count.value += 1;
  }

  decrement() {
    this._count.value -= 1;
  }
}

2. Use it in a Screen

// CounterScreen.tsx
import { useViewModel, useStream } from 'react-native-mobile-mvvm';
import { CounterViewModel } from './CounterViewModel';

const CounterScreen = () => {
  // Lifecycle is managed automatically — no useEffect, no cleanup boilerplate
  const vm = useViewModel(CounterViewModel);

  // ✅ Pass the state object directly — no need for .asObservable()
  const count = useStream(vm.count$, 0);

  return (
    <View>
      <Text style={{ fontSize: 48 }}>{count}</Text>
      <Button onPress={() => vm.increment()} title="+" />
      <Button onPress={() => vm.decrement()} title="−" />
    </View>
  );
};

That's it. No useEffect. No useState. No manual cleanup.


API Reference

ViewModel

Abstract base class for all ViewModels. Extend it and override onCleared() for custom cleanup.

import { ViewModel } from 'react-native-mobile-mvvm';

export class MyViewModel extends ViewModel {
  override onCleared() {
    // your custom cleanup here
    // Framework handles core cleanup (Aborting requests, completing destroy$)
  }
}

Protected Members

| Member | Type | Description | |---|---|---| | destroy$ | Observable<void> | Emits once when the ViewModel is cleared. Use with takeUntil(this.destroy$) to auto-cancel RxJS subscriptions. | | abortController | AbortController | Signal is automatically triggered on clear to cancel in-flight requests. |

Methods

| Method | Description | |---|---| | launch(task) | Runs an async task with an AbortSignal that is automatically triggered on clear. | | onCleared() | Called automatically on unmount. Override for custom cleanup. | | reactTo(source, ms, fn) | React to state changes with debounce and automatic cancellation. |

Example — using launch for automatic cancellation:

export class UserViewModel extends ViewModel {
  private _user = new StateFlow<User | null>(null);
  // ✅ Expose as ReadOnlyStateFlow — provides .value property
  public readonly user$: ReadOnlyStateFlow<User | null> = this._user;

  async fetchUser(id: string) {
    // ✅ launch provides automatic cancellation on unmount
    this.launch(async (signal) => {
      const res = await fetch(`/api/users/${id}`, { signal });
      this._user.value = await res.json();
    });
  }
}

Example — auto-cancelling an RxJS stream:

import { interval } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

export class TimerViewModel extends ViewModel {
  private _tick = new StateFlow<number>(0);
  public readonly tick$ = this._tick.asReadOnly();

  constructor() {
    super();
    interval(1000)
      .pipe(takeUntil(this.destroy$)) // stops automatically on clear
      .subscribe((n) => (this._tick.value = n));
  }
}

StateFlow<T> & ReadOnlyStateFlow<T>

A reactive state container. Analogous to MutableStateFlow<T> and StateFlow<T> in Kotlin.

const _count = new StateFlow<number>(0);

_count.value;           // Read current value synchronously
_count.value = 42;      // Mutate — all subscribers are notified
_count.asReadOnly();    // Returns ReadOnlyStateFlow<T>

ReadOnlyStateFlow<T>

Exposes the current value and an Observable for updates. Derived states from ComputedStateFlow also implement this.

interface ReadOnlyStateFlow<T> {
  readonly value: T;           // Synchronous access
  asObservable(): Observable<T>; // Observe updates
}

StateFlow Constructor

| Parameter | Type | Description | |---|---|---| | initialValue | T | The starting value of the state. | | isEqual | (a: T, b: T) => boolean | Optional. Equality check — skips emit if returns true. Default: Object.is. |


useStream<T>(source, defaultValue)

Subscribes to an Observable or ReadOnlyStateFlow and returns its latest value as React state.

// ✅ Pass the state object directly
const count = useStream(vm.count$, 0);

| Parameter | Type | Description | |---|---|---| | source | Observable<T> \| ReadOnlyStateFlow<T> | The source to subscribe to. | | defaultValue | T | Returned if source hasn't emitted (only for raw Observables). |

Behaviour:

  • Subscribes on mount, unsubscribes on unmount.
  • Re-subscribes if the observable$ reference changes.
  • Only triggers a re-render when the value actually changes.
  • For StateFlow (BehaviorSubject), reads the current value synchronously on first render — no flicker with defaultValue.
  • AppState-aware — pauses subscription when app goes to background, resumes on foreground. Analog to collectAsStateWithLifecycle() in Compose. Saves battery and prevents stale updates while UI is not visible.

useInit(fn)

Runs a callback exactly once when the component mounts. Handles both sync and async callbacks.

Analogous to LaunchedEffect(Unit) in Compose, initState() in Flutter, and .task { } in SwiftUI.

const UserScreen = () => {
  const vm = useViewModel(UserViewModel);

  // ❌ Before — developer must remember empty dependency array
  useEffect(() => { vm.fetchUser('123'); }, []);

  // ✅ After — intent is clear, no dependency array footgun
  useInit(() => vm.fetchUser('123'));

  // Async works too — errors handled inside ViewModel via UiState
  useInit(async () => {
    await vm.loadDashboard();
  });

  const { data, isLoading, isError, error } = useUiState(vm.userState$);
  // ...
};

Note: Do not use useInit for subscriptions or cleanup — use useLifecycle for those.


useLifecycle(onMount, onUnmount)

Runs onMount when the component appears and onUnmount when it disappears. Both are called exactly once.

When to use useInit vs useLifecycle

| Hook | Use when… | |---|---| | useInit(fn) | Trigger a one-shot action on mount. No cleanup needed (e.g. vm.fetchUser()) | | useLifecycle(onMount, onUnmount) | Start a resource on mount and stop it on unmount (e.g. tracking, sockets, sensors) |

Full Example

import { useViewModel, useStream, useLifecycle } from 'react-native-mobile-mvvm';
import { MapViewModel } from './MapViewModel';

const MapScreen = () => {
  const vm = useViewModel(MapViewModel);

  // ✅ Two clear callbacks — no magic return function
  useLifecycle(
    () => vm.startLocationTracking(),
    () => vm.stopLocationTracking(),
  );

  const location = useStream(vm.location$, null);

  return <MapView region={location} />;
};

Common use cases:

  • GPS / location tracking
  • WebSocket connections
  • Bluetooth / sensor listeners
  • Analytics session tracking
  • Background timers that need explicit cancellation

| Parameter | Type | Description | |---|---|---| | onMount | () => void | Called once when the component mounts | | onUnmount | () => void | Called once when the component unmounts (guaranteed) |


EventFlow<T>

A fire-and-forget event stream. Does not replay to new subscribers — emit once, done.
Analogous to SharedFlow(replay=0) / Channel in Kotlin, or StreamController one-shot in Flutter.

Use EventFlow for one-time side effects: navigation, snackbars, dialogs, toasts.
Use StateFlow for anything the UI needs to display.

// CheckoutViewModel.ts
import { ViewModel, StateFlow, EventFlow } from 'react-native-mobile-mvvm';

export class CheckoutViewModel extends ViewModel {
  // State — useStream() reads this
  private _isLoading = new StateFlow<boolean>(false);
  public readonly isLoading$ = this._isLoading.asReadOnly();

  // Events — useEvent() listens to these, never replayed
  private _navigateTo = new EventFlow<string>();
  private _showSnackbar = new EventFlow<string>();

  public readonly navigateTo$ = this._navigateTo.asObservable();
  public readonly showSnackbar$ = this._showSnackbar.asObservable();

  async placeOrder() {
    this._isLoading.value = true;
    try {
      await fetch('/api/orders', {
        method: 'POST',
        signal: this.abortController.signal,
      });
      // Fire once — new subscribers will NOT receive this
      this._navigateTo.emit('OrderSuccessScreen');
    } catch (e) {
      if ((e as Error).name !== 'AbortError') {
        this._showSnackbar.emit('Order failed. Please try again.');
      }
    } finally {
      this._isLoading.value = false;
    }
  }
}

Members

| Member | Type | Description | |---|---|---| | emit(value) | void | Fires the event to all current subscribers. New subscribers will not receive it. | | asObservable() | Observable<T> | Exposes a read-only stream to the UI. |


useEvent<T>(observable$, handler)

Subscribes to an EventFlow observable and runs a side-effect callback — without causing a re-render.
Analogous to BlocListener in Flutter or LaunchedEffect + collectLatest in Compose.

Do not use useStream for EventFlow. useStream stores state and triggers re-renders, which is wrong for fire-and-forget events.

// CheckoutScreen.tsx
import { useViewModel, useStream, useEvent } from 'react-native-mobile-mvvm';
import { useNavigation } from '@react-navigation/native';
import { CheckoutViewModel } from './CheckoutViewModel';

const CheckoutScreen = () => {
  const vm = useViewModel(CheckoutViewModel);
  const navigation = useNavigation();

  // State — renders when isLoading changes
  const isLoading = useStream(vm.isLoading$, false);

  // ✅ Events — optimized with "latest ref" pattern.
  // Safe to use inline functions — NO re-subscription on re-render!
  useEvent(vm.navigateTo$, (route) => {
    navigation.navigate(route as never);
  });

  useEvent(vm.showSnackbar$, (message) => {
    Snackbar.show({ text: message, duration: Snackbar.LENGTH_SHORT });
  });

  return (
    <View>
      <Button
        title={isLoading ? 'Placing order...' : 'Place Order'}
        disabled={isLoading}
        onPress={() => vm.placeOrder()}
      />
    </View>
  );
};

Parameters

| Parameter | Type | Description | |---|---|---| | observable$ | Observable<T> | The EventFlow observable to listen to. | | handler | (value: T) => void | Side-effect callback. Safe to use inline arrow functions. |

Behaviour:

  • Does NOT store the value in React state — no re-render.
  • Subscribes on mount, unsubscribes on unmount.
  • Optimized: Does NOT re-subscribe when the handler reference changes.

ComputedStateFlow

Derives a new ReadOnlyStateFlow from one or more StateFlow instances. Sugar over combineLatest + map.

Analogous to derivedStateOf {} in Compose, combine() in Swift/Combine, and combineLatest() in RxDart/Flutter.

// ✅ Derived — updates automatically and provides .value
public readonly filteredList$: ReadOnlyStateFlow<Product[]> = ComputedStateFlow.from(
  [this._items, this._query],
  ([items, query]) => items.filter((i) => i.name.includes(query)),
);

// Access synchronously in the ViewModel
get results() { return this.filteredList$.value; }

Full example — multiple sources, multiple derived states:

// ProductListViewModel.ts
import { ViewModel, StateFlow, ComputedStateFlow, ReadOnlyStateFlow } from 'react-native-mobile-mvvm';

export class ProductListViewModel extends ViewModel {
  private _products = new StateFlow<Product[]>([]);
  private _searchQuery = new StateFlow<string>('');

  // Derived — recomputes automatically when any source changes
  public readonly filteredProducts$: ReadOnlyStateFlow<Product[]> = ComputedStateFlow.from(
    [this._products, this._searchQuery],
    ([products, query]) => products.filter((p) => p.name.includes(query)),
  );

  // Single-source derived state
  public readonly resultCount$ = ComputedStateFlow.from(
    [this._products],
    ([products]) => products.length,
  );
}
// ProductListScreen.tsx
const ProductListScreen = () => {
  const vm = useViewModel(ProductListViewModel);

  // ✅ Pass derived states directly — no need for .asObservable()
  const products = useStream(vm.filteredProducts$, []);
  const resultCount = useStream(vm.resultCount$, 0);
  // ...
};

API

| | Description | |---|---| | ComputedStateFlow.from(sources, compute) | Creates a derived ReadOnlyStateFlow from an array of StateFlow instances |

| Parameter | Type | Description | |---|---|---| | sources | StateFlow<T>[] | One or more StateFlow instances to observe | | compute | (values: T[]) => R | Pure function that derives the new value. Receives current values of all sources as a typed tuple. |

Returns: ReadOnlyStateFlow<R>


UiState<T> + useUiState()

A sealed state type for async operations. Replaces the anti-pattern of three separate StateFlows (isLoading, data, error) with a single, mutually exclusive state.

Analogous to sealed class UiState in Kotlin/Compose and AsyncSnapshot + ConnectionState in Flutter.

In the ViewModel:

export class UserViewModel extends ViewModel {
  // ✅ One source of truth
  private _userState = new StateFlow<UiState<User>>(UiState.idle());
  public readonly userState$: ReadOnlyStateFlow<UiState<User>> = this._userState;

  async fetchUser(id: string) {
    this._userState.value = UiState.loading();
    try {
      // ✅ launch provides automatic cancellation on unmount
      await this.launch(async (signal) => {
        const res = await fetch(`/api/users/${id}`, { signal });
        const user = await res.json();
        this._userState.value = UiState.success(user);
      });
    } catch (e) {
      this._userState.value = UiState.error((e as Error).message);
    }
  }
}

In the UI — useUiState destructures into readable booleans:

const UserScreen = () => {
  const vm = useViewModel(UserViewModel);

  // ✅ Pass the state object directly
  const { data, isLoading, isError, error, isIdle } = useUiState(vm.userState$);

  if (isLoading) return <ActivityIndicator />;
  // ...
};

Pattern matching on raw state — TypeScript narrows the type per branch:

const { state } = useUiState(vm.userState$);

switch (state.status) {
  case 'idle':    return <Button title="Load" onPress={() => vm.fetchUser('1')} />;
  case 'loading': return <ActivityIndicator />;
  case 'success': return <Text>{state.data.name}</Text>; // state.data typed as User
  case 'error':   return <Text>{state.message}</Text>;
}

UiState<T> factory methods

| Method | Returns | Description | |---|---|---| | UiState.idle() | UiState<T> | Initial state — nothing loaded yet | | UiState.loading() | UiState<T> | Async operation in progress | | UiState.success(data) | UiState<T> | Operation succeeded, carries data | | UiState.error(message) | UiState<T> | Operation failed, carries error message |

useUiState(observable$, initialState?) return value

| Field | Type | Description | |---|---|---| | state | UiState<T> | Raw state — for exhaustive pattern matching | | data | T \| null | Data when status === 'success', null otherwise | | isIdle | boolean | True when status === 'idle' | | isLoading | boolean | True when status === 'loading' | | isSuccess | boolean | True when status === 'success' | | isError | boolean | True when status === 'error' | | error | string \| null | Error message when status === 'error', null otherwise |


ViewModelScope + useScopedViewModel()

Share a single ViewModel instance across multiple screens or components. The instance lives as long as the <ViewModelScope> is mounted — not tied to any individual component lifecycle.

Analogous to hiltViewModel(navBackStackEntry) in Compose, MultiProvider scope in Flutter, and @EnvironmentObject in SwiftUI.

useViewModel vs useScopedViewModel:

| | useViewModel | useScopedViewModel | |---|---|---| | Instance | New per component | Shared within scope | | Lifetime | Component lifetime | Scope lifetime | | Use case | Screen-local state | Cross-screen shared state | | Cleanup | On component unmount | On scope unmount |

Step 1 — wrap a navigator or screen group with <ViewModelScope>:

// AppNavigator.tsx
import { ViewModelScope } from 'react-native-mobile-mvvm';

export const CheckoutNavigator = () => (
  // All screens inside share the same CheckoutViewModel instance
  <ViewModelScope>
    <Stack.Navigator>
      <Stack.Screen name="Cart" component={CartScreen} />
      <Stack.Screen name="Checkout" component={CheckoutScreen} />
      <Stack.Screen name="Payment" component={PaymentScreen} />
    </Stack.Navigator>
  </ViewModelScope>
);
// When the user navigates away from the checkout flow entirely,
// ViewModelScope unmounts → CheckoutViewModel.onCleared() is called automatically.

Step 2 — call useScopedViewModel in any screen inside the scope:

// CartScreen.tsx
import { useScopedViewModel, useUiState } from 'react-native-mobile-mvvm';
import { CheckoutViewModel } from './CheckoutViewModel';

const CartScreen = () => {
  // ✅ Resolved via Class (DI or no-arg constructor)
  const vm = useScopedViewModel(CheckoutViewModel);
  
  // OR: ✅ Manual instantiation with arguments
  const vmManual = useScopedViewModel(CheckoutViewModel, () => new CheckoutViewModel(api));

  const { data: cart, isLoading } = useUiState(vm.cartState$);

  return (
    <View>
      {isLoading && <ActivityIndicator />}
      {cart && <CartList items={cart.items} />}
      <Button title="Proceed to Checkout" onPress={() => vm.validateCart()} />
    </View>
  );
};

// CheckoutScreen.tsx — receives the SAME CheckoutViewModel instance
const CheckoutScreen = () => {
  const vm = useScopedViewModel(CheckoutViewModel);
  // vm.cartState$ already has the validated cart from CartScreen — no reload needed
  const { data: cart } = useUiState(vm.cartState$);

  return <Text>Total: ${cart?.total}</Text>;
};

Note: useScopedViewModel throws if called outside a <ViewModelScope>. For per-screen ViewModels that don't need sharing, keep using useViewModel.


Dependency Injection

This package provides a flexible DI system that is truly optional. You can choose between automated resolution using decorators (powered by tsyringe) or manual injection using factory functions.

1. Manual Injection (No setup required)

If you want to avoid decorators and reflect-metadata, simply pass a factory function to useViewModel. This is the easiest way to pass arguments (like a userId from props) to your ViewModel.

const UserScreen = ({ userId }: Props) => {
  // ✅ Manual instantiation — no decorators or DI container needed
  const vm = useViewModel(() => new UserViewModel(userId, myApiService));
  
  // ...
};

2. Automated DI (using tsyringe)

For larger applications, you can use the built-in DI support. This requires installing additional peer dependencies:

npm install tsyringe reflect-metadata

And enabling decorator support in your tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Setup — call once at the app entry point

// App.tsx — MUST be the first import
import 'reflect-metadata';
import { configureDI, container } from 'react-native-mobile-mvvm/di';

configureDI(() => {
  container.register('AuthRepo', { useClass: AuthRepoImpl });
});

Define an injectable ViewModel

import { ViewModel, StateFlow } from 'react-native-mobile-mvvm';
import { Injectable, Inject } from 'react-native-mobile-mvvm/di';

@Injectable()
export class LoginViewModel extends ViewModel {
  constructor(
    @Inject('AuthRepo') private authRepo: AuthRepo,
  ) {
    super();
  }
  // ...
}

Use in a screen

const LoginScreen = () => {
  // ✅ Auto-resolved — dependencies are injected automatically
  const vm = useViewModel(LoginViewModel);
  // ...
};

DI Decorators

All decorators are re-exported from tsyringe with PascalCase aliases:

| This Package | tsyringe | Description | |---|---|---| | @Injectable() | @injectable() | Marks a class as resolvable by the container | | @Singleton() | @singleton() | Registers as a singleton scope | | @Inject(token) | @inject(token) | Injects a dependency by token | | @AutoInjectable() | @autoInjectable() | Resolves constructor params automatically | | @Scoped(scope) | @scoped(scope) | Registers with a custom lifecycle scope | | configureDI(fn) | — | Runs DI setup callback at app startup | | getContainer | container | Direct access to the tsyringe container |


Real-World Example — Full Feature ViewModel

import { ViewModel, StateFlow } from 'react-native-mobile-mvvm';
import { Injectable, Inject } from 'react-native-mobile-mvvm/di';
import { combineLatest } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';

interface Product {
  id: string;
  name: string;
  price: number;
}

@Injectable()
export class ProductListViewModel extends ViewModel {
  private _products = new StateFlow<Product[]>([]);
  private _searchQuery = new StateFlow<string>('');
  private _isLoading = new StateFlow<boolean>(false);
  private _error = new StateFlow<string | null>(null);

  public readonly isLoading$ = this._isLoading.asReadOnly();
  public readonly error$ = this._error.asReadOnly();

  // Derived state — filtered products based on search query
  public readonly filteredProducts$ = combineLatest([
    this._products,
    this._searchQuery,
  ]).pipe(
    map(([products, query]) =>
      query.trim()
        ? products.filter((p) =>
            p.name.toLowerCase().includes(query.toLowerCase()),
          )
        : products,
    ),
    takeUntil(this.destroy$), // auto-cancelled on unmount
  );

  constructor(
    @Inject('ProductRepository') private repo: ProductRepository,
  ) {
    super();
  }

  async loadProducts() {
    this._isLoading.value = true;
    this._error.value = null;
    try {
      const data = await this.repo.getAll({
        signal: this.abortController.signal,
      });
      this._products.value = data;
    } catch (e) {
      if ((e as Error).name !== 'AbortError') {
        this._error.value = 'Failed to load products.';
      }
    } finally {
      this._isLoading.value = false;
    }
  }

  onSearchChanged(query: string) {
    this._searchQuery.value = query;
  }
}

Recipes

Common real-world patterns with idiomatic solutions. These are the equivalent of the "idioms" section in official Compose documentation.

🔍 Search with Debounce (reactTo)

The most common pattern in any app with a search bar. In Compose, the idiomatic solution is:

// Compose — idiomatic
viewModelScope.launch {
  snapshotFlow { searchQuery }
    .debounce(300)
    .distinctUntilChanged()
    .collectLatest { query -> search(query) }
}

This package provides reactTo() — a protected method on ViewModel that composes the exact same pipeline:

// This package — reads like Compose, no RxJS operators to import
export class SearchViewModel extends ViewModel {
  private _query   = new StateFlow<string>('');
  private _results = new StateFlow<Product[]>([]);

  public readonly query$   = this._query.asReadOnly();
  public readonly results$ = this._results.asReadOnly();

  constructor() {
    super();
    // ✅ One line — debounce + distinctUntilChanged + switchMap + takeUntil all handled
    this.reactTo(this._query, 300, async (q) => {
      this._results.value = await productApi.search(q);
    });
  }

  onQueryChanged(query: string) { this._query.value = query; }
}
// SearchScreen.tsx
const SearchScreen = () => {
  const vm = useViewModel(SearchViewModel);
  const results = useStream(vm.results$, []);

  return (
    <View>
      <TextInput
        placeholder="Search products..."
        onChangeText={(t) => vm.onQueryChanged(t)}
      />
      <FlatList
        data={results}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => <Text>{item.name}</Text>}
      />
    </View>
  );
};

What reactTo does internally

stateFlow.subject
  .pipe(
    debounceTime(300),      // wait 300ms after last keystroke
    distinctUntilChanged(), // skip if value didn't change
    switchMap(async (q) => {
      // switchMap = collectLatest — cancels previous async call
      // if user types again before it finishes
      this._results.value = await productApi.search(q);
    }),
    takeUntil(this.destroy$), // auto-cleanup on unmount
  )
  .subscribe();

For developers coming from Compose: switchMap is the RxJS equivalent of collectLatest — it cancels the previous call when a new value arrives. This prevents stale results from a slow request arriving after a newer, faster request.

reactTo API

| Parameter | Type | Description | |---|---|---| | stateFlow | StateFlow<T> | The state to observe | | debounceMs | number | Milliseconds to wait after last change. Use 0 to skip debouncing | | handler | (value: T) => void \| Promise<void> | Called with the latest value. Previous call is cancelled if a new value arrives |

Variants

// Immediate reaction — no debounce, but still cancels stale calls (switchMap)
this.reactTo(this._selectedTab, 0, (tab) => this.loadTabContent(tab));

// Longer debounce for expensive operations (e.g. full-text search API)
this.reactTo(this._searchQuery, 500, async (q) => {
  this._results.value = await this.repo.fullTextSearch(q);
});

Prefer manual RxJS? That's fine too.

reactTo is sugar over standard RxJS. If you need operators not covered by reactTo (e.g. mergeMap, retry, catchError), write the pipeline directly — destroy$ and subject are there for you:

import { debounceTime, distinctUntilChanged, switchMap, takeUntil, catchError, EMPTY } from 'rxjs';

this._query.subject
  .pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((q) => from(productApi.search(q)).pipe(
      catchError(() => EMPTY), // handle search errors without killing the stream
    )),
    takeUntil(this.destroy$),
  )
  .subscribe((results) => (this._results.value = results));

🛰️ Resource Lifecycle (Start on Mount / Stop on Unmount)

The equivalent of DisposableEffect in Compose. Use useLifecycle in the UI layer:

// Compose
DisposableEffect(Unit) {
  vm.startLocationTracking()
  onDispose { vm.stopLocationTracking() }
}
// This package — identical contract
useLifecycle(
  () => vm.startLocationTracking(),
  () => vm.stopLocationTracking(),
);

See useLifecycle in the API reference for the full documentation.


Peer Dependencies

| Package | Version | Required | |---|---|---| | react | >=18 | ✅ Always | | react-native | >=0.71 | ✅ Always | | rxjs | ^7 | ✅ Always | | tsyringe | ^4 | ⚡ Only with DI | | reflect-metadata | ^0.2 | ⚡ Only with DI |


Tech Stack

| Layer | Technology | |---|---| | Language | TypeScript (strict, experimentalDecorators) | | Reactive Engine | RxJS 7 (BehaviorSubject, Subject, takeUntil) | | DI Container | TSyringe + reflect-metadata | | Build | tsup (CJS + ESM + .d.ts) |


License

MIT © Wildan Frananda