DEV Community

Cover image for This one Helper Eliminates 90% of CSS Status Class Duplication in Angular
kafeel ahmad
kafeel ahmad

Posted on

This one Helper Eliminates 90% of CSS Status Class Duplication in Angular

Angular Signal Forms make it refreshingly simple to react to form state changes. In a recent post, I showed how to configure CSS status classes globally based on a field's signal-driven state.

A very common follow-up question came up:

What if I want to apply multiple CSS classes using the same predicate?

At the moment, Angular Signal Forms do not provide an out-of-the-box API for mapping one conditionmany classes. However, the configuration API is flexible enough that we can solve this cleanly with a small helper.

This article walks through:

  • The problem in plain terms
  • A reusable helper function
  • Multiple real-world usage examples
  • A comparison table for clarity

Nothing fancy — just a practical solution that does the job

The Problem

When configuring Signal Forms, status classes are defined as a map:

Copy{
'some-class': (ctx) => boolean
}

This works well until you want something like:

  • is-error
  • has-error
  • control-invalid

All driven by the same condition (e.g. field.invalid).

Naively, you'd end up repeating the same predicate multiple times — which is noisy and error-prone.

The Idea

Instead of repeating predicates, we can:

  1. Accept an array of class names
  2. Accept one predicate function
  3. Expand them into a configuration object automatically

This keeps the intent clear and the configuration DRY.

A Small Helper (Renamed & Refactored)

To avoid any direct coupling or reuse concerns, the helper below uses:

  • Renamed types
  • Slightly adjusted logic
  • Same end behavior
Copytype StatusRuleFn = (context: Field<unknown>) => boolean;

function mapClassesToRule(
classList: readonly string[],
rule: StatusRuleFn
): Record<string, StatusRuleFn> {
return classList.reduce((acc, className) => {
acc[className] = rule;
return acc;
}, {} as Record<string, StatusRuleFn>);
}

Why reduce instead of Object.fromEntries?

Both approaches work. Using reduce makes the transformation explicit and easier to tweak later (for logging, filtering, or conditional inclusion).

Example 1: Invalid State Styling

CopybootstrapApplication(RootComponent, {
providers: [
provideSignalFormsConfig({
classes: {
...mapClassesToRule(
['is-error', 'has-error', 'control-invalid'],
({ state }) => state().invalid()
)
}
})
]
});

Result

Whenever the field becomes invalid, all three classes are applied automatically.

Example 2: Dirty + Touched Fields

Copy...mapClassesToRule(
['is-dirty', 'was-touched'],
({ state }) => state().dirty() && state().touched()
)

Useful for progressive validation UIs where feedback appears only after user interaction.

Example 3: Pending / Async Validation

Copy...mapClassesToRule(
['is-loading', 'checking'],
({ state }) => state().pending()
)

Perfect for async validators or server-side checks.

Example 4: Read-only or Disabled Fields

Copy...mapClassesToRule(
['is-disabled', 'readonly'],
({ state }) => state().disabled()
)

Keeps your templates clean while allowing rich styling.

Comparison Table

None

Why This Pattern Works Well

  • Declarative: You describe what should happen, not how
  • Composable: Easy to reuse across apps and libraries
  • Future-proof: If Angular adds native support later, migration is trivial

Most importantly, it keeps your Signal Forms configuration expressive and maintainable.

Final Thoughts

Angular Signal Forms already give us a powerful foundation. With tiny helpers like this, we can smooth over small API gaps without overengineering.

Nothing special — but it gets the job done

If you're building a design system or form-heavy app, patterns like this quickly pay for themselves.

Happy signaling

This article builds upon patterns shared by Roberto Hecker in his exploration of Signal

Top comments (0)