Skip to content

Instantly share code, notes, and snippets.

@matttelliott
Last active January 10, 2022 21:23
Show Gist options
  • Save matttelliott/19be64f02aa6a81a84a6f837328671ea to your computer and use it in GitHub Desktop.
Save matttelliott/19be64f02aa6a81a84a6f837328671ea to your computer and use it in GitHub Desktop.
Generic component actions

Say we have a bunch of components that emit a stream of ngrx actions

class MyComponent {
  @Output()
  public readonly action$: Observable<Action>
}

Each component emits its own specific actions. Instead of the generic Action, we want to expose a union of the actions the component emits, such as

class LogInComponent {
  @Output()
  public readonly action$: Observable<LogInComponentAction> = merge(
    of(logInFormSubmit({ formValues })),
    of(buttonClickSignUp())
  )
}

export const logInFormSubmit = createAction(
  'Log In Form Submit',
  props<{ formValues }>()
)

export const buttonClickSignUp = createAction('Sign Up Button Clicked')

export type LogInComponentAction =
  | ReturnType<typeof logInFormSubmit>
  | ReturnType<typeof buttonClickSignUp>

We want to be able to specify the more narrow type LogInComponentAction to consumers, but maintining this type by hand is tedious and error prone. Instead we can let typescript infer the types and export a type based on this inferrence. if we know we are going to be folling the convention @Output() action$..., we can create a generic type as follows

/*
 * ComponentActions
 * Extract action types from component with  @Output() action$
 */
export type ComponentActions<T extends ComponentWithActions> = NonNullable<
  ThenArg<ReturnType<T['action$']['toPromise']>>
>
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T
interface ComponentWithActions {
  readonly action$: Observable<unknown>
}

Then we can implement this type in our component as follows

class LogInComponent {
  // note that we no longer manually specify a type and instead let typescript infer it
  @Output()
  public readonly action$ = merge(
    of(logInFormSubmit({ formValues })),
    of(buttonClickSignUp())
  )
}

export const logInFormSubmit = createAction(
  'Log In Form Submit',
  props<{ formValues }>()
)

export const buttonClickSignUp = createAction('Sign Up Button Clicked')

export type LogInComponentAction =
  ComponentActions<LogInComponent>

This allows us to specify the LogInComponentAction type to consuming components

@Component({
  template: `
  <log-in-form (action$)="action$.next($event)"></log-in-form>
  <sign-up-form (action$)="action$.next($event)"></sign-up-form>
  `
})
export class AuthView implements OnDestroy {
  public readonly action$ = new Subject<AuthViewAction>()
  constructor(private readonly store: Store) { }
  dispatch = this.action$.subscribe(action => this.store.dispatch(action))
}
export type AuthViewAction = LogInComponentAction | SignUpComponentAction

This allows us to effortlessly maintain very strong typing through our component heirarchy througn an unbroken stream of Actions from the instigating user interactions up through a route-level view or "Smart" component where it can be dispatched and handled by the application.

See:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment