Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Components & templates
  2. Directives and .pipe(...)

Directives and .pipe(...)#

A Craft directive declares transformations: it can change the service a component takes, and it can wrap the template the component returns — so behaviour and markup travel together, and compose.

Use one when the same behaviour must be added to several components: a tooltip, a highlight, focus management, analytics on interaction. Not when the behaviour belongs to one component — put it in that component's own service.

Directives are applied from left to right.

Event actions and DOM modifiers#

eventAction(...) is an element directive. Use it when an element must adjust a DOM event before invoking one action. The action stays on the element; no craftMethod wrapper is needed:

ts
import { button, eventAction } from '@craft-ts/component';

button(
  'navToggle',
  {
    type: 'button',
    'aria-expanded': navOpen,
  },
  navOpen.navToggleLabel,
).pipe(
  eventAction({
    click: { action: navOpen.toggle, stopPropagation: true },
  }),
);

Each event entry requires action and can set preventDefault, stopPropagation, or stopImmediatePropagation to true. The modifiers run before the action in the same DOM listener. The action remains in Craft's normal event pipeline, including event hooks and generator callbacks. Use the event name as the key, such as click, submit, or keydown. Do not also put that event in the element's props; eventAction rejects duplicate handlers.

The recommended ESLint rule craft-ts/no-event-only-craft-method and the default architecture rule no-event-only-craft-method report a craftMethod that only modifies an event and delegates to one action, even when that method is declared in a different file from the element.

ts
import {
  button,
  craftComponent,
  craftDirective,
  div,
  p,
  type Input,
} from '@craft-ts/component';
import { overrideService } from '@craft-ts/core';

The shape#

ts
craftDirective(name, meta, transforms);
KeyWhat it does
transforms.servicereplaces a service in the scope the directive installs
transforms.templatewraps the template, and may decide not to render it

Both are optional: a directive may transform only the service, only the template, or both.

InteractivePermissions#

The examples below use a directive that enriches the service a component reads with a permission façade. Its configuration is internal to the directive; the component caller only provides the original user input.

ts
import { Input, craftDirective } from '@craft-ts/component';
import { craftService, craftUse, overrideService, craftExpose, type CraftServiceInput } from '@craft-ts/core';

type User = { id?: string; name: string; permissions: readonly string[] };

const { UserPanelView, provideUserPanelView } = craftService(
  { name: 'userPanelView', providedIn: 'toProvide' },
  function* (inputs: {
    readonly $provided: { readonly user: CraftServiceInput<User> };
  }) {
    yield* craftExpose('user', inputs.$provided.user);
  },
);

// A directive transforms the service the component takes. It may enrich what
// the template reads — here a permission façade — and it never adds a prop.
const InteractivePermissions = craftDirective(
  'InteractivePermissions',
  {},
  {
    service: overrideService(UserPanelView, (base) => ({
      ...base,
      canEdit: () => craftUse(base.user()).permissions.includes('edit'),
    })),
  },
);

Basic composition#

.pipe(...) installs the transformation in the component's own scope:

ts
const Card = craftComponent(
  'Card',
  { providers: [provideUserPanelView()] },
  function* (inputs: { readonly user: Input<User> }) {
    const { user } = yield* UserPanelView(inputs);
    return div(user.name);
  },
).pipe(InteractivePermissions);

What the component reads is resolved in the scope the directive installed:

component inputs
        ↓
service the component yields
        ↓
service transform installed by the directive
        ↓
the nodes the component returns
        ↓
template transform installed by the directive

The service the directive overrides must be the one the component takes. overrideService reaches the original instance with skipSelf, so the replacement can build on it instead of duplicating it.

Directive configuration#

A fixed configuration can be supplied when the directive is created:

ts
const hasPermission = (permission: Permission) =>
  craftDirective('hasPermission', {}, {
    service: overrideService(UserPanelView, (base) => ({
      ...base,
      canAccess: () => craftUse(base.user()).permissions.includes(permission),
    })),
  });

const Card = craftComponent(
  'Card',
  { providers: [provideUserPanelView()] },
  function* (inputs: { readonly user: Input<User> }) {
    const { user } = yield* UserPanelView(inputs);
    return div(user.name);
  },
).pipe(hasPermission('edit'));

edit is internal configuration. The caller of Card does not provide it.

Input supplied by the component caller#

A template transform reads the inputs it needs, and those inputs become inputs of the composed component:

ts
const hasPermissionInput = craftDirective('hasPermissionInput', {}, {
  template:
    (baseTemplate) =>
    (inputs: {
      readonly user: Input<User>;
      readonly permission: Input<Permission>;
    }) =>
      craftUse(inputs.user()).permissions.includes(
        craftUse(inputs.permission()),
      )
        ? baseTemplate(inputs)
        : [],
});

const Card = craftComponent(
  'Card',
  {},
  ({ user }: { readonly user: Input<User> }) => div(user.name),
).pipe(hasPermissionInput);

Card({
  user: () => currentUser,
  permission: () => 'edit',
});

The directive adds permission to Card's public props. A service transform never does: it may enrich or restrict what the template reads, but the public surface of a component stays what its own parameter declares.

Structural directive#

A structural directive decides whether the template produces nodes:

ts
import {
  Input,
  craftComponent,
  craftDirective,
  div,
  p,
} from '@craft-ts/component';
import { craftSignal, craftUse } from '@craft-ts/core';

const isVisible = craftSignal(true);

// A directive declares transformations. `template` wraps the template the
// component already has; the inputs it reads become inputs of the component.
const whenDirective = craftDirective(
  'whenDirective',
  {},
  {
    template:
      (baseTemplate) => (inputs: { readonly when: Input<boolean> }) =>
        craftUse(inputs.when()) ? baseTemplate(inputs) : [],
  },
);

const Panel = craftComponent('Panel', {}, () =>
  div(p('Conditional content')),
).pipe(whenDirective);

Panel({
  when: function* () {
    return isVisible();
  },
});

When when() becomes false, the renderer removes the template output. When it becomes true again, the template is rendered again.

A structural directive can consume what a previous directive added:

ts
const onlyEditable = craftDirective('onlyEditable', {}, {
  template: (baseTemplate) => (inputs) =>
    craftUse(UserPanelView().canEdit()) ? baseTemplate(inputs) : [],
});

const EditableCard = craftComponent(
  'EditableCard',
  { providers: [provideUserPanelView()] },
  function* (inputs: { readonly user: Input<User> }) {
    const { user } = yield* UserPanelView(inputs);
    return div(user.name);
  },
).pipe(InteractivePermissions, onlyEditable);

Transformations apply from left to right:

the component's own service
  → InteractivePermissions  (adds `canEdit`)
  → onlyEditable            (reads it, renders or not)

Directives on nodes#

A component can also apply a directive to a hyperscript node. The transform is then installed in a child scope limited to that subtree — the rest of the component keeps the service it had:

ts
const message = p('Message').pipe(whenDirective);

Craft directives can therefore transform part of a template without introducing an intermediate component.

Functional DOM directives can also receive their configuration directly and be applied with .pipe(...). The configuration is owned by the directive instead of becoming a DOM attribute:

ts
a({}, 'Tasks').pipe(CraftRouterLink(link));

A field configured with insertSelectFormTree must be selected before it is bound, so its lazy insertions (including validators) are registered:

ts
input({ type: 'email' }).pipe(
  CraftFieldDirective(loginForm.form.selectEmail()),
);

Composition rules#

  • Create a configurable directive with craftDirective(...), then pass it to .pipe(...).

  • A template transform can add public inputs; they appear in the final component props. A service transform cannot: it changes what the template reads, never what the caller passes.

  • .pipe(...) on a component installs the transformation in the component's scope; .pipe(...) on a node limits it to that subtree.

  • A directive placed after another sees the already transformed service and template, so it can consume what the previous one added.

  • withProviders(...) and the exception handlers travel through the same .pipe(...), as environment operators rather than service transforms.

  • Generator functions continue to be executed by the Craft runtime. Dependencies from the component and from its directives remain part of the component dependency contract.

See Also#

  • Customization

  • Styling a component

  • Testing components

←Progressive renderingsettledValue & pendingNode→

On this page

  • Event actions and DOM modifiers
  • The shape
  • InteractivePermissions
  • Basic composition
  • Directive configuration
  • Input supplied by the component caller
  • Structural directive
  • Directives on nodes
  • Composition rules
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search