npm package: @idiosync/react-observable
Description: State management control layer for React projects
Author: James Trickey
Latest version: 1.1.64

# @idiosync/react-observable

A lightweight, type-safe, and reactive state management library for React and React Native applications. Designed for composability, streams, and ergonomic integration with React hooks and context.

## Why Not Redux? (What This Library Fixes)

While Redux is powerful, it comes with pain points that `@idiosync/react-observable` solves:

- **Less Boilerplate**: No actions, reducers, or action types needed
- **Natural Async Flows**: Command streams and async operators instead of thunks/sagas
- **Reactive by Default**: Components update automatically, no manual subscriptions
- **Modern React API**: Hooks that feel like React with dependency arrays
- **Type Safety**: Automatic inference with deep readonly types
- **Better Side Effects**: First-class command streams for side effects
- **Built-in Persistence**: No extra libraries needed for persistent state
- **Stream-Based Architecture**: Powerful stream operators for derived state and async operations
- **Context Integration**: Global store management with React context
- **Immutable**: Deep readonly types prevent accidental mutations
- **Nullable Control**: Fine-grained control over whether observables can contain `undefined` values

## Quick Start

### 1. Install

```bash
npm install @idiosync/react-observable
# or
yarn add @idiosync/react-observable
```

### 2. Create Your Store

Start by creating observables for your application state:

```typescript
// src/store/modules/user.ts
import { createObservable } from '@idiosync/react-observable'

export const user$ = createObservable<User>({
  initialValue: undefined,
})

// Non-nullable observable - cannot contain undefined and require an initial value
export const isAuthenticated$ = createObservable<boolean, false>({
  initialValue: false,
})
```

```typescript
// src/store/modules/settings.ts
import { createPersistentObservable } from '@idiosync/react-observable'

export const theme$ = createPersistentObservable<'light' | 'dark'>({
  initialValue: 'light',
})
```

### 3. Create Your Store

Combine your modules into a store:

```typescript
// src/store/index.ts
import { createStore } from '@idiosync/react-observable'
import * as user from './modules/user'
import * as settings from './modules/settings'

export function createAppStore() {
  const store = {
    user,
    settings,
  }

  createStore(store)
  return store
}
```

#### Merging Multiple Store Objects

When you have multiple store objects to combine (like local and shared stores), use the `mergeStoreObjects` utility:

```typescript
// src/store/index.ts
import { createStore, mergeStoreObjects } from '@idiosync/react-observable'
import * as localStore from './store'
import { sharedStore } from '@mycompany/shared-store'

export function createAppStore() {
  const store = mergeStoreObjects(localStore, sharedStore)

  createStore(store)
  return store
}
```

Note: In this example, the entire local store is exported using an index file (`./store`), unlike the previous examples where individual modules were imported separately. This pattern works well with `mergeStoreObjects` as it can handle the complete store structure.

`mergeStoreObjects` provides:

- **Type-safe merging**: Ensures all objects contain observables
- **Deep merging**: Combines nested objects recursively
- **Runtime validation**: Throws clear errors for invalid structures
- **Flexible typing**: Works with any observable types using duck typing

The function uses `Duckservable` (a duck-typed version of `Observable<any>`) to provide compile-time structure checking without generic type conflicts.

### 3. Type Augmentation (Required for Full Type Inference)

For the library to work properly with full type inference, create a type augmentation file:

```typescript
// src/types/react-observable.d.ts
import { createAppStore } from '../store'

type AppStore = ReturnType<typeof createAppStore>

declare module '@idiosync/react-observable' {
  export interface Store extends AppStore {}
}
```

**Important:** The file must have `.d.ts` extension and be included in your `tsconfig.json`:

```json
{
  "include": ["src/**/*", "src/types/react-observable.d.ts"]
}
```

You must also import this file in your project's main index file (e.g., `src/index.ts` or `src/App.tsx`):

```typescript
// src/index.ts
import './types/react-observable.d.ts'
// ... rest of your imports
```

This is a little complex, but you only have to do it once.

### 4. Set Up the Provider

Wrap your app with the provider (required for all hooks):

```typescript
// src/App.tsx
import { ReactObservableProvider } from '@idiosync/react-observable'
import { createAppStore } from './store'

createAppStore()

function App() {
  return (
    <ReactObservableProvider loading={<div>Loading...</div>}>
      <YourApp />
    </ReactObservableProvider>
  )
}
```

### 5. Use in Components

Now you can use observables in your components:

```typescript
import { useObservableValue } from '@idiosync/react-observable'

function UserProfile() {
  const user = useObservableValue(({ store }) => store.user.user$)
  const theme = useObservableValue(({ store }) => store.settings.theme$)

  if (!user) return <div>Please log in</div>

  return (
    <div className={`profile ${theme}`}>
      <h1>Welcome, {user.name}!</h1>
    </div>
  )
}
```

## React Hooks

### useObservableValue

Get the current value of an observable. Perfect for simple state access:

```typescript
import { useObservableValue } from '@idiosync/react-observable'

function Counter() {
  const count = useObservableValue(({ store }) => store.counter.count$)

  return <div>Count: {count}</div>
}
```

### useEffectStream

Create derived state that reacts to dependencies. This is kind of like a useEffect, useCallback and useState all rolled into one.
Use this for computed values or when you need to react to prop changes:

```typescript
import { useEffectStream } from '@idiosync/react-observable'

function MultipliedCounter({ multiplier }: { multiplier: number }) {
  const [result, trigger] = useEffectStream(
    ({ $, store }) =>
      $.withLatestFrom(store.counter.count$)
        .stream(([[mult], count]) => count * mult),
    [multiplier]
  )

  return <div>Result: {result}</div>
}

return (
  <Touchable onPress={trigger} />
)
```

#### Dependency Arrays vs Input Arrays

This library uses **input arrays** rather than React's dependency arrays:

- **Input Array**: The `$` observable receives dependency values as an array `[multiplier]`
- **Stream Destructuring**: `$.withLatestFrom(store.counter.count$)` emits `[[multiplier], count]`, hence `[[mult], count]`

When `multiplier` changes, it flows: `[multiplier]` → `$` → `[[multiplier], count]` → destructured as `[[mult], count]`

Unlike a useEffect, access to the component scope will not update, so you should never access component level variables directly.

### useStoreObservable

Access raw observables from the store (advanced usage):

```typescript
import { useStoreObservable } from '@idiosync/react-observable'

function CounterWithActions() {
  const counter$ = useStoreObservable(({ store }) => store.counter.count$)

  const increment = () => counter$.set(count => count + 1)
  const decrement = () => counter$.set(count => count - 1)

  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{counter$.get()}</span>
      <button onClick={increment}>+</button>
    </div>
  )
}
```

## Command Streams

Command streams are perfect for side effects like API calls:

```typescript
// src/commands/user.ts
import { createCommandStream } from '@idiosync/react-observable'

export const fetchUser = createCommandStream(({ $, store }) =>
  $.streamAsync(async ([userId]: [string]) => {
    const response = await fetch(`/api/users/${userId}`)
    const user = await response.json()

    // Update the store
    store.user.user$.set(user)
    store.user.isAuthenticated$.set(true)

    return user
  })
)

// Usage in component
function UserProfile({ userId }: { userId: string }) {
  const handleFetch = useCallback(async () => {
    const [user, error] = await fetchUser(userId)
    if (error) {
      console.error('Failed to fetch user:', error)
    }
  }, [userId])

  return <button onClick={handleFetch}>Load User</button>
}
```

## Observable Methods

### Basic Operations

```typescript
const counter$ = createObservable({ initialValue: 0 })

// Get current value
const current = counter$.get()

// Set new value
counter$.set(5)
counter$.set((current) => current + 1)

// Subscribe to changes
const unsubscribe = counter$.subscribe((value) => {
  console.log('Counter changed:', value)
})

// Clean up
unsubscribe()
```

### Stream Operations

```typescript
// Transform values
const doubled$ = counter$.stream((count) => count * 2)

// Async operations
const userData$ = userId$.streamAsync(async (id) => {
  const response = await fetch(`/api/users/${id}`)
  return response.json()
})

// Combine multiple observables
const combined$ = counter$.withLatestFrom(settings$)
const all$ = counter$.combineLatestFrom(settings$, theme$)
```

### Error Handling

```typescript
counter$
  .stream((value) => {
    if (value < 0) throw new Error('Negative counter!')
    return value
  })
  .catchError((error, currentValue) => {
    console.error('Counter error:', error)

    // If this value is returned the stream will be restored wiht the new value
    // Otherwise it will be halted
    return { restoreValue }
  })
```

### Stream Halting

Observables can be halted to signal that a stream should stop emitting values. This is different from RxJS completion semantics:

```typescript
const counter$ = createObservable({ initialValue: 0 })

// Subscribe with stream halted callback
counter$.subscribe(
  (value) => console.log('Value:', value),
  (error) => console.error('Error:', error),
  (stack) => console.log('Stream halted:', stack),
)

// Halt the stream
counter$.emitStreamHalted()

// After halting, no further values or errors will be emitted
counter$.set(5) // This won't trigger the listener
```

The `emitStreamHalted` method replaces the previous `emitComplete` method to avoid confusion with RxJS semantics. Stream halting is useful for:

- Signaling that a data source has finished
- Cleaning up resources
- Preventing further emissions in error recovery scenarios

### Observable Configuration

Observables support several configuration options:

```typescript
const counter$ = createObservable({
  initialValue: 0,
  name: 'counter', // Optional name for debugging
  equalityFn: (a, b) => a === b, // Custom equality function
  emitWhenValuesAreEqual: false, // Whether to emit when values are equal (default: false)
})
```

The `emitWhenValuesAreEqual` option controls whether the observable emits when the new value equals the current value. This option is inherited by downstream observables and defaults to `false` to prevent unnecessary re-renders.

## Nullable vs Non-Nullable Observables

The `IsNullable` parameter controls whether an observable can contain `undefined` values:

```typescript
// Nullable observable (default) - can contain undefined
const nullable$ = createObservable<string>({
  initialValue: 'hello',
})
// Type: Observable<string | undefined>

// Non-nullable observable - cannot contain undefined
const nonNullable$ = createObservable<string, false>({
  initialValue: 'hello',
})
// Type: Observable<string>

// Explicitly nullable observable
const explicitNullable$ = createObservable<string, true>()
// Type: Observable<string | undefined>
```

- IsNullable defaults to true
- If IsNullable is false, a initialValue is required

## Persistent Storage

### Creating Persistent Observables

Use `createPersistentObservable` to create observables that automatically persist their values to storage:

```typescript
import { createPersistentObservable } from '@idiosync/react-observable'

export const theme$ = createPersistentObservable<'light' | 'dark'>({
  initialValue: 'light',
  name: 'theme', // Required for persistence
})

// Nullable persistent observable
export const userPreferences$ = createPersistentObservable<
  UserPreferences | undefined,
  true
>({
  initialValue: { fontSize: 16, notifications: true },
  name: 'user-preferences',
  // Optional: Custom merge function for hydration
  mergeOnHydration: (initialValue, persisted) => ({
    ...initialValue,
    ...persisted,
  }),
})
```

### Error Handling

Storage errors are handled centrally by the library during hydration. If storage is unavailable or corrupted, observables will gracefully fall back to their initial values without throwing errors.

### Using AsyncStorage (React Native)

```typescript
import AsyncStorage from '@react-native-async-storage/async-storage'
import { PersistentStorage } from '@idiosync/react-observable'

const persistentStorage: PersistentStorage = {
  getItem: (key) => AsyncStorage.getItem(key),
  setItem: (key, value) => AsyncStorage.setItem(key, value),
  removeItem: (key) => AsyncStorage.removeItem(key),
}

// Pass to createStore
createStore(store, { persistentStorage })
```

### Using localStorage (Web)

```typescript
const persistentStorage: PersistentStorage = {
  getItem: async (key) => localStorage.getItem(key),
  setItem: async (key, value) => localStorage.setItem(key, value),
  removeItem: async (key) => localStorage.removeItem(key),
}
```

## Best Practices

### 1. Use Many Small Observables

Instead of one large observable, use many small ones for better performance:

```typescript
// ❌ Don't do this
const user$ = createObservable({
  initialValue: {
    name: '',
    email: '',
    preferences: { theme: 'light', fontSize: 16 },
  },
})

// ✅ Do this instead
const userName$ = createObservable({ initialValue: '' })
const userEmail$ = createObservable({ initialValue: '' })
const theme$ = createObservable({ initialValue: 'light' })
const fontSize$ = createObservable({ initialValue: 16 })
```

### 2. Always Use the Provider

All hooks require the `ReactObservableProvider`:

```typescript
// ❌ This will throw an error
function Component() {
  const value = useObservableValue(({ store }) => store.counter.count$)
  return <div>{value}</div>
}

// ✅ Wrap with provider
function App() {
  return (
    <ReactObservableProvider>
      <Component />
    </ReactObservableProvider>
  )
}
```

### 3. Use Command Streams for Side Effects

Keep components pure by moving side effects to command streams:

```typescript
// ❌ Don't do side effects in components
function UserProfile() {
  const handleSave = async () => {
    const response = await fetch('/api/user', { method: 'POST' })
    // ...
  }
}

// ✅ Use command streams
const saveUser = createCommandStream(({ $, store }) =>
  $.streamAsync(async ([userData]) => {
    const response = await fetch('/api/user', {
      method: 'POST',
      body: JSON.stringify(userData),
    })
    return response.json()
  }),
)
```

### 4. Use mapEntries for Large Objects

Where possible, break down large objects into individual observables for more targeted updates:

```typescript
// This is fine, but any hook or stream that stems from it will be re-run if any prop changes
const user$ = createObservable({
  initialValue: { id: 1, name: 'John', email: 'john@example.com', preferences: { theme: 'light' } }
})

// Better approach: Use mapEntries to create individual observables. This ensures that anything down stream will only trigger based on the specific property.
const user$ = createObservable({
  initialValue: { id: 1, name: 'John', email: 'john@example.com', preferences: { theme: 'light' } }
})

// Break down into individual observables
const { userName$, userEmail$, userPreferences$ } = user$.mapEntries({
  keys: ['name', 'email', 'preferences']
})

// Now only components using userName$ re-render when name changes
function UserName() {
  const name = useObservableValue(({ store }) => store.user.userName$)
  return <div>Name: {name}</div>
}

// This component only re-renders when email changes
function UserEmail() {
  const email = useObservableValue(({ store }) => store.user.userEmail$)
  return <div>Email: {email}</div>
}
```

This approach ensures that only components using specific properties re-render when those properties change, improving performance significantly.

## Observable API

Below are the core methods available on every Observable. Each method is shown with a brief example.

### get

Get the current value.

```typescript
const value = counter$.get()
```

### set

Set a new value (direct or functional update).

```typescript
counter$.set(5)
counter$.set((current) => current + 1)
```

### subscribe

Listen for value changes.

```typescript
const unsubscribe = counter$.subscribe((value) => console.log(value))
unsubscribe()
```

### stream

Create a derived observable by transforming values. Can be chained.

```typescript
const result$ = counter$
  .stream((count) => count * 2)
  .stream((double) => double + 1)
```

### streamAsync

Create a derived observable from an async function. Can be chained.

```typescript
const result$ = userId$
  .streamAsync(async (id) => await fetchUser(id))
  .stream((user) => user.name)
```

### tap

Run a side effect on every emission (returns a new observable, can be chained).

```typescript
const result$ = counter$
  .tap((value) => console.log('Tapped:', value))
  .stream((x) => x * 2)
```

### delay

Delay emissions by a given number of milliseconds. Can be chained.

```typescript
const result$ = counter$.delay(500).stream((x) => x * 2)
```

### catchError

Handle errors in the observable stream. Can be chained.

```typescript
const safe$ = counter$
  .stream((value) => {
    if (value < 0) throw new Error('Negative!')
    return value
  })
  .catchError((error, value) => ({ restoreValue: 0 }))
  .stream((x) => x + 1)
```

### combineLatestFrom

Combine with other observables and emit arrays of latest values. Can be chained.

```typescript
const result$ = counter$
  .combineLatestFrom(settings$, theme$)
  .stream(([count, settings, theme]) => `${count}-${theme}`)
```

### withLatestFrom

Combine with other observables, but only emit when the source emits. Can be chained.

```typescript
const result$ = counter$
  .withLatestFrom(settings$)
  .stream(([count, settings]) => count + settings.offset)
```

### mapEntries

For object observables, create observables for each property. Can be used in streams.

```typescript
const { name$, age$ } = user$.mapEntries({ keys: ['name', 'age'] })
const upperName$ = name$.stream((name) => name.toUpperCase())
```

### guard

Filter emissions based on a predicate. Can be chained.

```typescript
const increasing$ = counter$
  .guard((next, prev) => next > prev)
  .stream((x) => x * 2)
```

### finally

Run a callback on value, error, or completion events. Can be chained. Used for cleanup.

```typescript
const result$ = counter$
  .tap(() =>setIsLoading(true))
  .stream(() =>throw new Error("Load Failed"))
  .catchError((error) => logError(error))
  .finally((type, value, error) => {
    // regardless of what happened in the stream we need to set loading ot false.
    setLoading(false)
  })
```

### reset

Reset the observable to its initial value.

```typescript
counter$.reset()
```

### emit / emitError / emitStreamHalted

Manually emit a value, error, or halt the stream.

```typescript
counter$.emit()
counter$.emitError(new Error('Oops'))
counter$.emitStreamHalted()
```

---

## API Reference

### Core Functions

- `createObservable<T, IsNullable = true>(config): Observable<T | undefined>`
- `createPersistentObservable<T, IsNullable = true>(config): PersistentObservable<T | undefined>`
- `createStore(store, options?): void`
- `createCommandStream(initializer): CommandStream`

### Hooks

- `useObservableValue(initializer): T`
- `useEffectStream(initializer, dependencies): T`
- `useStoreObservable(initializer): Observable<T>`