Skip to content

Instantly share code, notes, and snippets.

@antischematic
Last active August 29, 2022 12:50
Show Gist options
  • Save antischematic/856f3ed041ca0532ea19860c84bcaa4d to your computer and use it in GitHub Desktop.
Save antischematic/856f3ed041ca0532ea19860c84bcaa4d to your computer and use it in GitHub Desktop.
Angular Reactivity Thought Experiment
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)
// 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
}
@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