Last active
October 28, 2024 05:52
-
-
Save monotykamary/562be9c98a122786c368fd2d08ba5fc4 to your computer and use it in GitHub Desktop.
Autorun RxJS with practices from Signals
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Fragment, FunctionComponent, ReactNode, createElement, useEffect, useState } from "react"; | |
| import { asyncScheduler, BehaviorSubject, combineLatest, Observable } from "rxjs"; | |
| import { debounceTime, distinctUntilChanged, map, startWith } from "rxjs/operators"; | |
| type ObservableMap<T> = Map<Observable<T>, number>; | |
| type ProxyHandlerType<K extends string | symbol, V> = { get(target: V, prop: K): V }; | |
| interface ProxyInterface { | |
| $: <T>(obs: Observable<T>) => T; | |
| } | |
| /** | |
| * Extracts all observables used in an expression function. | |
| * | |
| * @param {function} expressionFn - The function that contains the expression which depends on observables. | |
| * @returns {Observable<unknown>[]} - An array of observables used in the expression. | |
| */ | |
| function evaluateExpression(expressionFn: (proxy: ProxyInterface) => void): Observable<unknown>[] { | |
| const observables: ObservableMap<unknown> = new Map(); | |
| let index = 0; | |
| const proxyHandler: ProxyHandlerType<string | symbol, any> = { | |
| get(target, prop) { | |
| if (prop === "$") { | |
| return <U>(observable: Observable<U>): Observable<U> => { | |
| if (!observables.has(observable)) { | |
| observables.set(observable, index++); | |
| } | |
| return observable; | |
| }; | |
| } | |
| return target[prop]; | |
| }, | |
| }; | |
| const proxy = new Proxy({}, proxyHandler); | |
| expressionFn(proxy as any); | |
| return Array.from(observables.keys()); | |
| } | |
| /** | |
| * Creates a proxy object to map observables to their current values. | |
| * | |
| * @param {ObservableMap<T>} observablesMap - A map of observables to their indexes. | |
| * @param {unknown[]} values - An array of current values corresponding to the observables. | |
| * @returns {ProxyInterface} - A proxy interface providing current values of observables. | |
| */ | |
| function createProxy<T>(observablesMap: ObservableMap<T>, values: unknown[]): ProxyInterface { | |
| const proxyHandler: ProxyHandlerType<string | symbol, any> = { | |
| get(target, prop) { | |
| if (prop === "$") { | |
| return <U>(observable: Observable<U>): U | undefined => values[observablesMap.get(observable as any) as number] as U; | |
| } | |
| return target[prop]; | |
| }, | |
| }; | |
| return new Proxy({}, proxyHandler) as ProxyInterface; | |
| } | |
| /** | |
| * Creates a combined observable that recalculates its value based on dependencies. | |
| * | |
| * @param {function} expressionFn - The function that defines the combined value based on observables. | |
| * @returns {Observable<T>} - A new observable that emits the combined value. | |
| */ | |
| function combined<T>(expressionFn: (proxy: ProxyInterface) => T): Observable<T> { | |
| const observablesArray = evaluateExpression(expressionFn) as Observable<unknown>[]; | |
| const observablesMap: ObservableMap<unknown> = new Map( | |
| observablesArray.map((observable, index) => [observable, index]) | |
| ); | |
| const pipeline = combineLatest(observablesArray).pipe( | |
| map((values) => { | |
| const proxy = createProxy(observablesMap, values); | |
| return expressionFn(proxy); | |
| }), | |
| ); | |
| return pipeline; | |
| } | |
| /** | |
| * Creates a computed observable that recalculates its value based on dependencies. | |
| * | |
| * @param {function} expressionFn - The function that defines the computed value based on observables. | |
| * @returns {Observable<T>} - A new observable that emits the computed value. | |
| */ | |
| function computed<T>(expressionFn: (proxy: ProxyInterface) => T): Observable<T> { | |
| const observablesArray = evaluateExpression(expressionFn) as Observable<unknown>[]; | |
| const observablesMap: ObservableMap<unknown> = new Map( | |
| observablesArray.map((observable, index) => [observable, index]) | |
| ); | |
| const pipeline = combineLatest( | |
| observablesArray.map((observable) => observable.pipe( | |
| startWith(undefined), | |
| debounceTime(0, asyncScheduler), | |
| distinctUntilChanged(), | |
| )) | |
| ).pipe( | |
| map((values) => { | |
| const proxy = createProxy(observablesMap, values); | |
| return expressionFn(proxy); | |
| }), | |
| distinctUntilChanged(), | |
| ); | |
| return pipeline; | |
| } | |
| /** | |
| * React hook to subscribe to an observable and get its current value. | |
| * | |
| * @param {BehaviorSubject<T> | Observable<T> | function} observable - The observable or function to subscribe to. | |
| * @returns {(T | undefined)} - The current value of the observable. | |
| */ | |
| function useRx<T>(observable: BehaviorSubject<T>): T; | |
| function useRx<T>(observable: Observable<T>): T | undefined; | |
| function useRx<T>(observable: ((proxy: ProxyInterface) => T)): T | undefined; | |
| function useRx<T>(observable: any) { | |
| const [value, setValue] = useState<T | undefined>( | |
| observable instanceof BehaviorSubject | |
| ? observable.getValue() | |
| : undefined | |
| ); | |
| useEffect(() => { | |
| const effectiveObservable = | |
| typeof observable === 'function' ? computed(observable) : observable; | |
| const subscription = effectiveObservable.subscribe(setValue); | |
| return () => subscription.unsubscribe(); | |
| }, [observable]); | |
| return value; | |
| } | |
| /** | |
| * Helper function to create a React component that automatically subscribes to observables | |
| * and re-renders when any of the observables emit new values. | |
| * | |
| * @param {function} expressionFn - The function that defines the component's rendering logic based on observables. | |
| * @returns {FunctionComponent} - A React functional component. | |
| */ | |
| function rxComponent(expressionFn: (proxy: ProxyInterface) => ReactNode): FunctionComponent { | |
| return () => { | |
| const result = useRx(expressionFn); | |
| return createElement(Fragment, null, result); | |
| }; | |
| } | |
| export { combined, computed, useRx, rxComponent }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React from 'react'; | |
| import { BehaviorSubject } from 'rxjs'; | |
| import { map, delay } from 'rxjs/operators'; | |
| import { combined, computed, useRx, rxComponent } from './your-library'; | |
| // Example 1: Simple Counter | |
| const counterExample = () => { | |
| const count$ = new BehaviorSubject(0); | |
| const CounterComponent = () => { | |
| const count = useRx(count$); | |
| return ( | |
| <div> | |
| <h2>Counter: {count}</h2> | |
| <button onClick={() => count$.next(count$.getValue() + 1)}>Increment</button> | |
| <button onClick={() => count$.next(count$.getValue() - 1)}>Decrement</button> | |
| </div> | |
| ); | |
| }; | |
| return CounterComponent; | |
| }; | |
| // Example 2: Temperature Converter using computed | |
| const temperatureExample = () => { | |
| const celsius$ = new BehaviorSubject(0); | |
| // Computed fahrenheit observable | |
| const fahrenheit$ = computed(({ $ }) => { | |
| const c = $(celsius$); | |
| return (c * 9/5) + 32; | |
| }); | |
| const TemperatureComponent = () => { | |
| const celsius = useRx(celsius$); | |
| const fahrenheit = useRx(fahrenheit$); | |
| return ( | |
| <div> | |
| <h2>Temperature Converter</h2> | |
| <input | |
| type="number" | |
| value={celsius} | |
| onChange={(e) => celsius$.next(Number(e.target.value))} | |
| /> | |
| <p>{celsius}°C = {fahrenheit}°F</p> | |
| </div> | |
| ); | |
| }; | |
| return TemperatureComponent; | |
| }; | |
| // Example 3: Todo List with Combined Observables | |
| const todoExample = () => { | |
| const todos$ = new BehaviorSubject<string[]>([]); | |
| const filter$ = new BehaviorSubject<'all' | 'completed' | 'active'>('all'); | |
| const completedTodos$ = new BehaviorSubject<Set<number>>(new Set()); | |
| // Combined observable for filtered todos | |
| const filteredTodos$ = combined(({ $ }) => { | |
| const todos = $(todos$); | |
| const filter = $(filter$); | |
| const completed = $(completedTodos$); | |
| switch (filter) { | |
| case 'completed': | |
| return todos.filter((_, index) => completed.has(index)); | |
| case 'active': | |
| return todos.filter((_, index) => !completed.has(index)); | |
| default: | |
| return todos; | |
| } | |
| }); | |
| // Create a component using rxComponent helper | |
| const TodoStats = rxComponent(({ $ }) => { | |
| const total = $(todos$).length; | |
| const completed = $(completedTodos$).size; | |
| return ( | |
| <div className="stats"> | |
| <p>Total: {total} | Completed: {completed} | Active: {total - completed}</p> | |
| </div> | |
| ); | |
| }); | |
| const TodoListComponent = () => { | |
| const todos = useRx(filteredTodos$); | |
| const filter = useRx(filter$); | |
| const completed = useRx(completedTodos$); | |
| const addTodo = (text: string) => { | |
| todos$.next([...todos$.getValue(), text]); | |
| }; | |
| const toggleTodo = (index: number) => { | |
| const current = completedTodos$.getValue(); | |
| const updated = new Set(current); | |
| if (current.has(index)) { | |
| updated.delete(index); | |
| } else { | |
| updated.add(index); | |
| } | |
| completedTodos$.next(updated); | |
| }; | |
| return ( | |
| <div> | |
| <h2>Todo List</h2> | |
| <div> | |
| <input | |
| type="text" | |
| onKeyPress={(e) => { | |
| if (e.key === 'Enter') { | |
| addTodo((e.target as HTMLInputElement).value); | |
| (e.target as HTMLInputElement).value = ''; | |
| } | |
| }} | |
| placeholder="Add todo and press Enter" | |
| /> | |
| </div> | |
| <div> | |
| <select | |
| value={filter} | |
| onChange={(e) => filter$.next(e.target.value as any)} | |
| > | |
| <option value="all">All</option> | |
| <option value="active">Active</option> | |
| <option value="completed">Completed</option> | |
| </select> | |
| </div> | |
| <ul> | |
| {todos?.map((todo, index) => ( | |
| <li | |
| key={index} | |
| style={{ | |
| textDecoration: completed?.has(index) ? 'line-through' : 'none', | |
| cursor: 'pointer' | |
| }} | |
| onClick={() => toggleTodo(index)} | |
| > | |
| {todo} | |
| </li> | |
| ))} | |
| </ul> | |
| <TodoStats /> | |
| </div> | |
| ); | |
| }; | |
| return TodoListComponent; | |
| }; | |
| // Example 4: Async Data Loading | |
| const asyncExample = () => { | |
| const loading$ = new BehaviorSubject(false); | |
| const data$ = new BehaviorSubject<string[]>([]); | |
| const error$ = new BehaviorSubject<string | null>(null); | |
| // Simulated API call | |
| const fetchData = () => { | |
| loading$.next(true); | |
| error$.next(null); | |
| // Simulate API delay | |
| setTimeout(() => { | |
| const success = Math.random() > 0.3; // 70% success rate | |
| if (success) { | |
| data$.next(['Item 1', 'Item 2', 'Item 3']); | |
| loading$.next(false); | |
| } else { | |
| error$.next('Failed to fetch data'); | |
| loading$.next(false); | |
| } | |
| }, 1000); | |
| }; | |
| const AsyncComponent = () => { | |
| const loading = useRx(loading$); | |
| const data = useRx(data$); | |
| const error = useRx(error$); | |
| return ( | |
| <div> | |
| <h2>Async Data Loading</h2> | |
| <button onClick={fetchData} disabled={loading}> | |
| {loading ? 'Loading...' : 'Fetch Data'} | |
| </button> | |
| {error && <div style={{ color: 'red' }}>{error}</div>} | |
| {data && data.length > 0 && ( | |
| <ul> | |
| {data.map((item, index) => ( | |
| <li key={index}>{item}</li> | |
| ))} | |
| </ul> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| return AsyncComponent; | |
| }; | |
| export { counterExample, temperatureExample, todoExample, asyncExample }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // counter.component.ts | |
| import { Component, OnDestroy } from '@angular/core'; | |
| import { BehaviorSubject } from 'rxjs'; | |
| import { computed } from './your-library'; | |
| @Component({ | |
| selector: 'app-counter', | |
| template: ` | |
| <div class="counter-container"> | |
| <h2>Counter with Doubles</h2> | |
| <div class="value-display"> | |
| <p>Count: {{ count$ | async }}</p> | |
| <p>Double: {{ doubleCount$ | async }}</p> | |
| </div> | |
| <div class="controls"> | |
| <button (click)="increment()">+</button> | |
| <button (click)="decrement()">-</button> | |
| </div> | |
| </div> | |
| `, | |
| styles: [` | |
| .counter-container { | |
| padding: 20px; | |
| text-align: center; | |
| } | |
| .controls button { | |
| margin: 0 5px; | |
| padding: 5px 15px; | |
| } | |
| `] | |
| }) | |
| export class CounterComponent implements OnDestroy { | |
| private readonly count = new BehaviorSubject(0); | |
| readonly count$ = this.count.asObservable(); | |
| // Using computed for derived state | |
| readonly doubleCount$ = computed(({ $ }) => { | |
| const count = $(this.count$); | |
| return count * 2; | |
| }); | |
| increment() { | |
| this.count.next(this.count.getValue() + 1); | |
| } | |
| decrement() { | |
| this.count.next(this.count.getValue() - 1); | |
| } | |
| ngOnDestroy() { | |
| this.count.complete(); | |
| } | |
| } | |
| // todo-list.component.ts | |
| import { Component, OnDestroy } from '@angular/core'; | |
| import { BehaviorSubject } from 'rxjs'; | |
| import { combined } from './your-library'; | |
| interface Todo { | |
| id: number; | |
| text: string; | |
| completed: boolean; | |
| } | |
| type FilterType = 'all' | 'active' | 'completed'; | |
| @Component({ | |
| selector: 'app-todo-list', | |
| template: ` | |
| <div class="todo-container"> | |
| <h2>Todo List</h2> | |
| <div class="todo-input"> | |
| <input | |
| #todoInput | |
| (keyup.enter)="addTodo(todoInput.value); todoInput.value = ''" | |
| placeholder="Add new todo" | |
| > | |
| </div> | |
| <div class="filter-controls"> | |
| <button | |
| *ngFor="let filter of filters" | |
| [class.active]="(currentFilter$ | async) === filter" | |
| (click)="setFilter(filter)" | |
| > | |
| {{ filter | titlecase }} | |
| </button> | |
| </div> | |
| <ul class="todo-list"> | |
| <li *ngFor="let todo of filteredTodos$ | async"> | |
| <label> | |
| <input | |
| type="checkbox" | |
| [checked]="todo.completed" | |
| (change)="toggleTodo(todo.id)" | |
| > | |
| <span [class.completed]="todo.completed">{{ todo.text }}</span> | |
| </label> | |
| <button (click)="deleteTodo(todo.id)" class="delete-btn">×</button> | |
| </li> | |
| </ul> | |
| <div class="todo-stats"> | |
| <p>Total: {{ (stats$ | async)?.total }}</p> | |
| <p>Active: {{ (stats$ | async)?.active }}</p> | |
| <p>Completed: {{ (stats$ | async)?.completed }}</p> | |
| </div> | |
| </div> | |
| `, | |
| styles: [` | |
| .todo-container { | |
| max-width: 600px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| } | |
| .completed { | |
| text-decoration: line-through; | |
| color: #888; | |
| } | |
| .todo-list { | |
| list-style: none; | |
| padding: 0; | |
| } | |
| .todo-list li { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 8px; | |
| margin: 4px 0; | |
| background: #f5f5f5; | |
| } | |
| .delete-btn { | |
| color: red; | |
| border: none; | |
| background: none; | |
| cursor: pointer; | |
| } | |
| .filter-controls { | |
| margin: 15px 0; | |
| } | |
| .filter-controls button { | |
| margin: 0 5px; | |
| } | |
| .filter-controls button.active { | |
| background: #007bff; | |
| color: white; | |
| } | |
| `] | |
| }) | |
| export class TodoListComponent implements OnDestroy { | |
| private readonly todos = new BehaviorSubject<Todo[]>([]); | |
| private readonly currentFilter = new BehaviorSubject<FilterType>('all'); | |
| readonly currentFilter$ = this.currentFilter.asObservable(); | |
| readonly filters: FilterType[] = ['all', 'active', 'completed']; | |
| // Combined observable for filtered todos | |
| readonly filteredTodos$ = combined(({ $ }) => { | |
| const todos = $(this.todos); | |
| const filter = $(this.currentFilter); | |
| switch (filter) { | |
| case 'active': | |
| return todos.filter(todo => !todo.completed); | |
| case 'completed': | |
| return todos.filter(todo => todo.completed); | |
| default: | |
| return todos; | |
| } | |
| }); | |
| // Computed stats | |
| readonly stats$ = computed(({ $ }) => { | |
| const todos = $(this.todos); | |
| const completed = todos.filter(todo => todo.completed).length; | |
| const total = todos.length; | |
| return { | |
| total, | |
| completed, | |
| active: total - completed | |
| }; | |
| }); | |
| addTodo(text: string) { | |
| if (!text.trim()) return; | |
| const newTodo: Todo = { | |
| id: Date.now(), | |
| text: text.trim(), | |
| completed: false | |
| }; | |
| this.todos.next([...this.todos.getValue(), newTodo]); | |
| } | |
| toggleTodo(id: number) { | |
| const updated = this.todos.getValue().map(todo => | |
| todo.id === id ? { ...todo, completed: !todo.completed } : todo | |
| ); | |
| this.todos.next(updated); | |
| } | |
| deleteTodo(id: number) { | |
| const updated = this.todos.getValue().filter(todo => todo.id !== id); | |
| this.todos.next(updated); | |
| } | |
| setFilter(filter: FilterType) { | |
| this.currentFilter.next(filter); | |
| } | |
| ngOnDestroy() { | |
| this.todos.complete(); | |
| this.currentFilter.complete(); | |
| } | |
| } | |
| // weather-dashboard.component.ts | |
| import { Component, OnDestroy, OnInit } from '@angular/core'; | |
| import { BehaviorSubject } from 'rxjs'; | |
| import { computed } from './your-library'; | |
| interface WeatherData { | |
| temperature: number; | |
| humidity: number; | |
| windSpeed: number; | |
| timestamp: Date; | |
| } | |
| @Component({ | |
| selector: 'app-weather-dashboard', | |
| template: ` | |
| <div class="weather-dashboard"> | |
| <h2>Weather Dashboard</h2> | |
| <div class="refresh-control"> | |
| <button (click)="refreshData()" [disabled]="(loading$ | async)"> | |
| {{ (loading$ | async) ? 'Refreshing...' : 'Refresh Data' }} | |
| </button> | |
| <span *ngIf="lastUpdate$ | async as lastUpdate"> | |
| Last updated: {{ lastUpdate | date:'medium' }} | |
| </span> | |
| </div> | |
| <div class="weather-stats" *ngIf="weatherStats$ | async as stats"> | |
| <div class="stat-card"> | |
| <h3>Temperature</h3> | |
| <p>Current: {{ stats.currentTemp }}°C</p> | |
| <p>Average: {{ stats.avgTemp }}°C</p> | |
| <p>Max: {{ stats.maxTemp }}°C</p> | |
| <p>Min: {{ stats.minTemp }}°C</p> | |
| </div> | |
| <div class="stat-card"> | |
| <h3>Humidity</h3> | |
| <p>Current: {{ stats.currentHumidity }}%</p> | |
| <p>Average: {{ stats.avgHumidity }}%</p> | |
| </div> | |
| <div class="stat-card"> | |
| <h3>Wind Speed</h3> | |
| <p>Current: {{ stats.currentWind }} km/h</p> | |
| <p>Average: {{ stats.avgWind }} km/h</p> | |
| </div> | |
| </div> | |
| </div> | |
| `, | |
| styles: [` | |
| .weather-dashboard { | |
| padding: 20px; | |
| } | |
| .weather-stats { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 20px; | |
| margin-top: 20px; | |
| } | |
| .stat-card { | |
| padding: 15px; | |
| background: #f5f5f5; | |
| border-radius: 8px; | |
| } | |
| .refresh-control { | |
| display: flex; | |
| gap: 15px; | |
| align-items: center; | |
| } | |
| `] | |
| }) | |
| export class WeatherDashboardComponent implements OnInit, OnDestroy { | |
| private readonly weatherData = new BehaviorSubject<WeatherData[]>([]); | |
| private readonly loading = new BehaviorSubject<boolean>(false); | |
| readonly loading$ = this.loading.asObservable(); | |
| readonly lastUpdate$ = computed(({ $ }) => { | |
| const data = $(this.weatherData); | |
| return data.length ? data[data.length - 1].timestamp : null; | |
| }); | |
| readonly weatherStats$ = computed(({ $ }) => { | |
| const data = $(this.weatherData); | |
| if (!data.length) return null; | |
| const current = data[data.length - 1]; | |
| const calcAverage = (key: keyof WeatherData) => | |
| data.reduce((sum, item) => sum + Number(item[key]), 0) / data.length; | |
| return { | |
| currentTemp: current.temperature, | |
| avgTemp: calcAverage('temperature').toFixed(1), | |
| maxTemp: Math.max(...data.map(d => d.temperature)), | |
| minTemp: Math.min(...data.map(d => d.temperature)), | |
| currentHumidity: current.humidity, | |
| avgHumidity: calcAverage('humidity').toFixed(1), | |
| currentWind: current.windSpeed, | |
| avgWind: calcAverage('windSpeed').toFixed(1) | |
| }; | |
| }); | |
| ngOnInit() { | |
| this.refreshData(); | |
| } | |
| refreshData() { | |
| this.loading.next(true); | |
| // Simulate API call | |
| setTimeout(() => { | |
| const newData: WeatherData = { | |
| temperature: 20 + Math.random() * 10, | |
| humidity: 40 + Math.random() * 30, | |
| windSpeed: 5 + Math.random() * 20, | |
| timestamp: new Date() | |
| }; | |
| const currentData = this.weatherData.getValue(); | |
| this.weatherData.next([...currentData, newData].slice(-24)); // Keep last 24 readings | |
| this.loading.next(false); | |
| }, 1000); | |
| } | |
| ngOnDestroy() { | |
| this.weatherData.complete(); | |
| this.loading.complete(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment