UNPKG

@idiosync/react-observable

Version:

State management control layer for React projects

747 lines (546 loc) 19.9 kB
# @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>`