Last active
August 29, 2022 12:50
-
-
Save antischematic/856f3ed041ca0532ea19860c84bcaa4d to your computer and use it in GitHub Desktop.
Angular Reactivity Thought Experiment
This file contains 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
interface ActionConfig { | |
// enables or disables default proxy-based dependency tracking on `this` | |
// default: false | |
track?: boolean | |
// whether the action should be executed on init | |
// this option is ignored if function.length > 0 | |
// default: false | |
immediate?: boolean | |
// run action during ngDoCheck when dependencies change | |
// default: true | |
check?: boolean | |
// run action during ngAfterContentChecked when dependencies change | |
// default: false | |
content?: boolean | |
// run action during ngAfterViewChecked when dependencies change | |
// default: false | |
view?: boolean | |
} | |
/** | |
* 1. Create environment injector for each action | |
* 2. Wrap actions in injection context | |
* 3. Wrap action `this` context in shallow proxy | |
* 4. Mark tracked properties as deps | |
* 5. Check deps in lifecycle hooks | |
* 6. Trigger when dep values change | |
* 7. Also trigger when called imperatively | |
*/ | |
@Store() | |
@Component({ | |
template: ` | |
<button (click)="callback(1)">Click</button> | |
` | |
}) | |
export class UITodos { | |
@Input() input | |
@Output() output = new EventEmitter() | |
@ContentChild(ChildDirective) | |
childDirective: ChildDirective | |
list = [] | |
options = [] | |
// dependency tracking with memoization. | |
// shallow property access on `this` will be tracked as a dependency | |
// `$()` to shallow check objects, maps, sets and arrays | |
@Select() get computed() { | |
return $(this.list).filter(item => item.property) | |
} | |
// memoize dependencies and function arguments. | |
@Select() select(value) { | |
return this.options.filter(item => item.value === value) | |
} | |
@Action() callback(value) { | |
// selectors always unwrap their value to prevent object identity hazards. | |
return dispatch(loadData(this.select(value)), { | |
next(list) { | |
this.list = list | |
output.emit(list) | |
} | |
}) | |
} | |
// alias for @Action({ track: true, immediate: true, check: true }) | |
// shallow proxy-based dependency tracking. | |
// executes during ngDoCheck/ngAfterContentChecked/ngAfterViewChecked unless function params are present or `immediate: false`. | |
// if function params are present, becomes reactive after first imperative call. | |
// executes when dependencies change. The params from the last imperative call are reused. | |
// runs in an injection context where `inject` can be used | |
@Invoke() action() { | |
// injected values are not reactive | |
const { output } = inject(UITodos) | |
// subscribes to an observable source and triggers change detection | |
// automatically unsubscribed when the action is called again or the view is destroyed | |
return dispatch(loadData(this.input), { | |
next(list) { | |
this.list = list | |
// action chain | |
this.callback(2) | |
}, | |
error(error: ErrorEvent) { | |
console.error(error.value) | |
if (error.tries < 3) { | |
this.action() | |
} else { | |
throw error | |
} | |
} | |
}) | |
} | |
// alias for @Action({ track: true, immediate: true, content: true }) | |
@Before() content() { | |
console.log(this.childDirective) // reactive | |
} | |
// alias for @Action({ track: true, immediate: true, view: true }) | |
// Teardown function or subscription can be returned. When a teardown | |
// logic is returned, it is executed before the action next runs. | |
@Layout() mount() { | |
const instance = new ThirdPartyLibrary(inject(ElementRef)) | |
return () => instance.destroy() | |
} | |
// handler for uncaught errors in actions | |
// if the error is not handled, propagates to the next handler if present | |
@Caught() handleError(error) { | |
console.log(error) | |
} | |
// example using createEffect to control the stream | |
@Action() createData(value) { | |
return dispatch(createData(value), { | |
next(list) { | |
this.list = list | |
} | |
}) | |
} | |
// events from actions can be observed | |
@Action() saga() { | |
return dispatch(fromAction(UITodos), { | |
next(event /** Event **/) { | |
// type Event = | |
// | { name: "createData", action: true, value: inferred } | |
// | { name: "createData", subscribe: true } | |
// | { name: "createData", next: true, value: inferred } | |
// | { name: "createData", error: true, value: unknown, tries: number } | |
// | { name: "createData", complete: true } | |
// | { name: "createData", unsubscribe: true } | |
console.log(event) | |
} | |
}) | |
} | |
// Synergy with HostListener | |
@Action() | |
@HostListener("click", ["$event"]) | |
handleClick(event) { | |
console.log(this.input) | |
} | |
// Synergy with HostBinding | |
@Select() | |
@HostBinding("attr.class") | |
get className() { | |
return this.list.map(v => v.name) | |
} | |
} | |
// extract and compose data streams with dependency injection | |
function loadData(params) { | |
return inject(HttpClient).get(endpoint, params) | |
} | |
// The createEffect helper gives us full control over the data stream. | |
// createEffect can only be called inside an action stack frame. | |
// Only one effect can be created per action. | |
function createData(data) { | |
const http = inject(HttpClient) | |
return createEffect( | |
// fetches some data when subscribed to | |
http.post(endpoint, data), | |
// skips fetch until the previous fetch has completed | |
// ie. to prevent double submit action | |
exhaustAll() | |
) | |
}) | |
// Creates a dispatcher bound to the directive | |
// Dispatchers can only be called inside an action stack frame | |
// Actions can only dispatch one effect at a time | |
const dispatch = createDispatch(UITodos) |
This file contains 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
// We could easily add finite state machines to restrict calls to actions based on state | |
const UITodosMachine = createMachine({ | |
ready: { | |
// simple action to state mapping | |
action: "loading" | |
}, | |
loading: { | |
// tapping into effects | |
action: { | |
complete: "done" | |
} | |
}, | |
// final state | |
done: { | |
navigate: true | |
} | |
}) | |
@Store() | |
@Component({ | |
template: ` | |
<button [disabled]="!ready" (click)="action()">Click</button> | |
`, | |
providers: [ | |
provideStore({ | |
features: [UITodosMachine] | |
}) | |
] | |
}) | |
export class UITodos { | |
// attach FSM states | |
@State() ready: boolean | |
@State() loading: boolean | |
@State() done: boolean | |
// action names are mapped to FSM | |
// if action is called from an invalid state an error is thrown | |
@Action() action() { | |
return dispatch(timer(500), { | |
// effect callbacks are also mapped to FSM | |
complete() { | |
console.log("done") | |
} | |
}) | |
} | |
// react to state changes | |
@Action() navigate() { | |
const router = inject(Router) | |
if (this.done) { | |
return dispatch(router.navigate("next-page")) | |
} | |
} | |
} | |
// We could also support transition zones | |
// https://dev.to/mmuscat/angular-transition-zone-3ki3 | |
@Store() | |
@Component({ | |
template: ` | |
<spinner *ngIf="pending"></spinner> | |
<button ui-button (click)="action()"> | |
<span>Click</span> | |
</button> | |
` | |
}) | |
export class UITodos { | |
// true when any action is queued (ie. NgZone.hasPendingMacroTasks = true) | |
@Queue() pending = false | |
// true when a specific action is queued | |
@Queue("action") actionPending = false | |
// when initialized with a number, counts how many transitions are queued | |
// ie. when calling action multiple times before previous action completes | |
@Queue("action") actionPendingCount = 0 | |
// Each action is wrapped in a transition zone so we can track async activity | |
@Invoke() action() { | |
// Limit number of concurrent effects | |
if (this.actionPendingCount < 5) { | |
// merge timers so they don't get cancelled | |
return dispatch(createEffect(timer(2000), mergeAll())) | |
} | |
} | |
} | |
// Observe transitions from dom events | |
@Store() | |
@Component({ | |
selector: "button[ui-button]", | |
template: ` | |
<ng-content></ng-content> | |
<spinner *ngIf="pending"></spinner> | |
` | |
}) | |
export class UIButton { | |
// note: Requires EventManager extension | |
// support transitions triggered by dom events | |
// only track latest transition in the queue | |
@Queue("element:click", { limit: 1 }) pending = false | |
} |
This file contains 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
@Store() | |
@Component({ | |
imports: [UITodo], | |
selector: 'ui-todos', | |
standalone: true, | |
template: ` | |
<h2>Add todo</h2> | |
<ui-todo (save)="createTodo($event)"></ui-todo> | |
<h2>{{remaining.length}} todos remaining</h2> | |
<ui-todo *ngFor="let todo of remaining" [value]="todo" (save)="updateTodo($event)"></ui-todo> | |
<h2>{{completed.length}} of {{todos.length}} todos completed</h2> | |
<ui-todo *ngFor="let todo of completed" [value]="todo" (save)="updateTodo($event)"></ui-todo> | |
`, | |
}) | |
export class UITodos { | |
@Input() userId!: string | |
todos: Todo[] = [] | |
@Select() get remaining() { | |
return this.todos.filter(todo => !todo.complete) | |
} | |
@Select() get completed() { | |
return this.todos.filter(todo => todo.complete) | |
} | |
@Invoke() loadTodos() { | |
return dispatch(loadTodos(this.userId), { | |
next(todos) { | |
this.todos = todos | |
} | |
}) | |
} | |
@Action() createTodo(todo: Todo) { | |
return dispatch(createTodo(todo.text), { | |
next: this.loadTodos | |
}) | |
} | |
@Action() updateTodo(todo: Todo) { | |
return dispatch(updateTodo(todo), { | |
next: this.loadTodos | |
}) | |
} | |
} | |
const dispatch = createDispatch(UITodos) | |
function loadTodos(userId: string): Observable<Todo[]> { | |
return inject(HttpClient).get(`https://jsonplaceholder.typicode.com/todos?userId=${userId}`) | |
} | |
function createTodo(text: string): Observable<Todo> { | |
return createEffect( | |
inject(HttpClient).post('https://jsonplaceholder.typicode.com/todos', { text }), | |
mergeAll() | |
) | |
} | |
function updateTodo(todo: Todo) { | |
return createEffect( | |
inject(HttpClient).put(`https://jsonplaceholder.typicode.com/todos/${todo.id}`, todo), | |
exhaustAll() | |
) | |
} | |
@Component({ | |
selector: 'ui-todo', | |
standalone: true, | |
template: ` | |
<input type="checkbox" [disabled]="!value.id" [checked]="value.complete" (change)="toggleComplete($event.target.checked)" /> | |
<input type="text" [disabled]="value.complete" [value]="value.text" (keydown.enter)="updateText($event.target.value)" /> | |
` | |
}) | |
export class UITodo { | |
@Input() value: Todo = UITodo.defaultValue | |
@Output() save = new EventEmitter<Todo>() | |
toggleComplete(complete: boolean) { | |
this.handleChange({ | |
...this.value, | |
complete | |
}) | |
} | |
updateText(text: string) { | |
this.handleChange({ | |
...this.value, | |
text | |
}) | |
} | |
handleChange(change: Todo) { | |
this.save.emit(change) | |
if (!this.value.id) { | |
this.value = UITodo.defaultValue | |
} | |
} | |
static defaultValue = { | |
id: undefined, | |
text: "", | |
complete: false | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment