5.6 KiB
5.6 KiB
title, impact, impactDescription, tags
| title | impact | impactDescription | tags |
|---|---|---|---|
| Compose Helpers for Reusable Logic | MEDIUM-HIGH | Better code reuse and testability | helpers, composition, functions, pipes, reusability |
Compose Helpers for Reusable Logic
Compose helpers to create reusable, testable logic that can be combined in templates and components.
Incorrect (logic duplicated in templates):
// app/components/user-profile.gjs
<template>
<div class="profile">
<h1>{{uppercase (truncate @user.name 20)}}</h1>
{{#if (and @user.isActive (not @user.isDeleted))}}
<span class="status">Active</span>
{{/if}}
<p>{{lowercase @user.email}}</p>
{{#if (gt @user.posts.length 0)}}
<span>Posts: {{@user.posts.length}}</span>
{{/if}}
</div>
</template>
Correct (composed helpers):
// app/helpers/display-name.js
export function displayName(name, { maxLength = 20 } = {}) {
if (!name) return '';
const truncated = name.length > maxLength ? name.slice(0, maxLength) + '...' : name;
return truncated.toUpperCase();
}
// app/helpers/is-visible-user.js
export function isVisibleUser(user) {
return user && user.isActive && !user.isDeleted;
}
// app/helpers/format-email.js
export function formatEmail(email) {
return email?.toLowerCase() || '';
}
// app/components/user-profile.gjs
import { displayName } from '../helpers/display-name';
import { isVisibleUser } from '../helpers/is-visible-user';
import { formatEmail } from '../helpers/format-email';
<template>
<div class="profile">
<h1>{{displayName @user.name}}</h1>
{{#if (isVisibleUser @user)}}
<span class="status">Active</span>
{{/if}}
<p>{{formatEmail @user.email}}</p>
{{#if (gt @user.posts.length 0)}}
<span>Posts: {{@user.posts.length}}</span>
{{/if}}
</div>
</template>
Functional composition with pipe helper:
// app/helpers/pipe.js
export function pipe(...fns) {
return (value) => fns.reduce((acc, fn) => fn(acc), value);
}
Or use a compose helper:
// app/helpers/compose.js
export function compose(...helperFns) {
return (value) => helperFns.reduceRight((acc, fn) => fn(acc), value);
}
Usage:
// app/components/text-processor.gjs
import { fn } from '@ember/helper';
// Individual helpers
const uppercase = (str) => str?.toUpperCase() || '';
const trim = (str) => str?.trim() || '';
const truncate = (str, length = 20) => str?.slice(0, length) || '';
<template>
{{! Compose multiple transformations }}
<div>
{{pipe @text (fn trim) (fn uppercase) (fn truncate 50)}}
</div>
</template>
Higher-order helpers:
// app/helpers/partial-apply.js
export function partialApply(fn, ...args) {
return (...moreArgs) => fn(...args, ...moreArgs);
}
// app/helpers/map-by.js
export function mapBy(array, property) {
return array?.map((item) => item[property]) || [];
}
// Usage in template
import { mapBy } from '../helpers/map-by';
import { partialApply } from '../helpers/partial-apply';
<template>
{{! Extract property from array }}
<ul>
{{#each (mapBy @users "name") as |name|}}
<li>{{name}}</li>
{{/each}}
</ul>
{{! Partial application }}
{{#let (partialApply @formatNumber 2) as |formatTwoDecimals|}}
<span>Price: {{formatTwoDecimals @price}}</span>
{{/let}}
</template>
Chainable transformation helpers:
// app/helpers/transform.js
class Transform {
constructor(value) {
this.value = value;
}
filter(fn) {
this.value = this.value?.filter(fn) || [];
return this;
}
map(fn) {
this.value = this.value?.map(fn) || [];
return this;
}
sort(fn) {
this.value = [...(this.value || [])].sort(fn);
return this;
}
take(n) {
this.value = this.value?.slice(0, n) || [];
return this;
}
get result() {
return this.value;
}
}
export function transform(value) {
return new Transform(value);
}
// Usage
import { transform } from '../helpers/transform';
function filter(items) {
return items
.filter((item) => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
.take(10).result;
}
<template>
{{#let (transform @items) as |t|}}
{{#each (filter t) as |item|}}
<div>{{item.name}}</div>
{{/each}}
{{/let}}
</template>
Conditional composition:
// app/helpers/when.js
export function when(condition, trueFn, falseFn) {
return condition ? trueFn() : falseFn ? falseFn() : null;
}
// app/helpers/unless.js
export function unless(condition, falseFn, trueFn) {
return !condition ? falseFn() : trueFn ? trueFn() : null;
}
Testing composed helpers:
// tests/helpers/display-name-test.js
import { module, test } from 'qunit';
import { displayName } from 'my-app/helpers/display-name';
module('Unit | Helper | display-name', function () {
test('it formats name correctly', function (assert) {
assert.strictEqual(displayName('John Doe'), 'JOHN DOE');
});
test('it truncates long names', function (assert) {
assert.strictEqual(
displayName('A Very Long Name That Should Be Truncated', { maxLength: 10 }),
'A VERY LON...',
);
});
test('it handles null', function (assert) {
assert.strictEqual(displayName(null), '');
});
});
Composed helpers provide testable, reusable logic that keeps templates clean and components focused on behavior rather than data transformation.
Reference: Ember Helpers