Skip to content

UI Component Common Issues Across Frameworks

JessicaOPRD edited this page Jun 26, 2023 · 1 revision

Using one-way data binding

In the name of unidirectional flow, one-way binding has become "the way." It helps reduce a lot of headaches.

AngularJS

For more information on all scope binding options, see the $compile object in AngularJS's documentation (I always, always forgot where it was when I was regularly using this framework).

Unfortunately, AngularJS popularized "two-way" or "bidirectional" binding. In the case of templates, it remains a must-have. Nobody wants to write DOM updates manually if we can help it. However, in the case of flow of data between components and services, two-way binding causes a lot of issues as applications grow and become more complicated. In AngularJS, it is established via the scope object with =. If you want to calculate a value to send to a component, you may inevitably encounter an infamous error:

Error: $compile:nonassign
Non-Assignable Expression

This is likely happening because you cannot two-way bind an expression. For instance:

<component-a
  hero-status="isHero()"
></component-a>

... which is bound like so:

scope: {
  isHero: '=?heroStatus'
},

What you want instead is one-way binding, with ^, but this only exists in later versions of the AngularJS framework. If the value you see is a string, many times you can pass as a string value instead (&). However, in the case of booleans and numbers that must remain numbers, it gets more tricky. Solutions I have done:

  1. Where you can, delegate boolean flag computations to the data retrieval in the first place. This isolates the computation to "the source of truth" (the persisted data) and passes it through the rest of the application tiers (server and client) without needing to be repeated or needing live computation. I do this as much as I can. Of course, in some scenarios, the answer can only be provided in the interface/browser experience. But a LOT of times, the data itself can provide the answer.

  2. Calculate the outcome of the computation/expression one time or when needed and append it to an model/representative object, so instead of attempting to calculation with isHero() you refer to a isHero variable.

  3. Pass a function (&) and calculate the value as needed inside the component. This can get tricky/confusing. See below for tips regarding passing a function to a child component.

Invoking event handler (function) passing child data to parent scope

AngularJS

You do not have to rely on $scope beyond the component definition for this to work.

If the event is custom (not ng-change from ng-model, etc), the following will work:

Assign event listener to child component as passed parameter of type &, signaling a function:

scope: {
  specialChangeFn: '&?opSpecialChange'
}

When triggering the local/internal onSpecialChange handler in the child, confirm a parent handler was provided on the parent instance:

if (angular.isDefined($attrs.specialChangeFn) && angular.isFunction(vm.specialChangeFn)) {
  vm.specialChangeFn({
    $modelValue: emittedValue
  });
}

It is not enough to attempt angular.isFunction(vm.specialChangeFn). It will always exist as a function when defined with &, even if a handler hasn't actually been assigned. (This may depend on version, but check for it.) You may need to wrap the custom event callback above in a $timeout depending on where the emitted value comes from. You cannot trust that another ng-model or property has been updated yet.

In the parent the assignment will look like:

<op-radio-chip-input
  op-special-change="$ctrl.onSeatCapacityChange($modelValue)"
>

Notice the event value is passed as an object with named properties to match emitted property names. This allows you to pair the emitted values with template-level values as well. For instance:

<op-radio-chip-input
  op-special-change="$ctrl.onSeatCapacityChange('equipment', $modelValue)"
>

The parent's handler would look like:

function onSeatCapacityChange(type, emittedValue) {
  console.log('The value has changed', type, emittedValue);
}

Vue

Vue and other newer JavaScript frameworks make this easier. You will simply emit events with event payloads from the child, which the parent will capture and handle. Because these are component-level event handlers, you never need to pass them as parameters like was necessary in AngularJS.

Inside the child component:

watch: {
  internalValue(v) {
    this.$emit('special-change', v)
  }
}

In the parent:

<op-radio-chip-input
  @special-change="onSeatCapacityChange($event)"
>

Or:

<op-radio-chip-input
  @special-change="onSeatCapacityChange('equipment', $event)"
>

Note that unlike AngularJS the emitted parameter names(s) are not determined by the child. They are always $event. $event can be a context object, boolean, string, etc. Once captured by the parent's event handler, the handler's signature can provide additional details.

Parent's handler:

function onSeatCapacityChange(type, emittedValue) {
  console.log('The value has changed', type, emittedValue);
}

Creating template-level reference to component API

My first convention was to explicitly name this property api. However, in newer frameworks I often see the ref attribute used for this purpose.

AngularJS

This can be done by assigning an object as a property within the component.

scope: {
  api: '=?opApi'
},
angular.extend(vm, {
  api: {}
});

The intended "public" functions can be assigned like so:

angular.extend(vm.api, {
  getNumPages: getNumPages
});

The reference will be captured like so from the parent:

<op-pagination
  op-api="$ctrl.paginationApi"
>
</op-pagination>

Testing what this does?

Clone this wiki locally