Skip to content

Instantly share code, notes, and snippets.

@monotykamary
Last active October 28, 2024 05:52
Show Gist options
  • Select an option

  • Save monotykamary/562be9c98a122786c368fd2d08ba5fc4 to your computer and use it in GitHub Desktop.

Select an option

Save monotykamary/562be9c98a122786c368fd2d08ba5fc4 to your computer and use it in GitHub Desktop.
Autorun RxJS with practices from Signals
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 };
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 };
// 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