Properties, state and rendering
- Zeta Flutter
- Zeta Web
Properties
All external configuration is passed through the constructor. Flutter widgets are immutable — use final for every field:
// ✅ Good — immutable, final fields
const ZetaFoo({
super.key,
super.rounded,
required this.label,
this.disabled = false,
});
final String label;
final bool disabled;
// ❌ Bad — mutable field, breaks Flutter's build model
String label = '';
Reactive state
Use ZetaStatefulWidget + State only when the component owns UI state that is not derivable from its parameters (e.g. animation progress, focus node, scroll controller). Prefer lifting state to the parent:
class ZetaAccordion extends ZetaStatefulWidget {
const ZetaAccordion({super.key, super.rounded, required this.title, required this.child});
final String title;
final Widget child;
State<ZetaAccordion> createState() => _ZetaAccordionState();
}
class _ZetaAccordionState extends State<ZetaAccordion> {
bool _expanded = false;
Widget build(BuildContext context) { ... }
}
Internal references
Store controllers and focus nodes as State fields when they must be created once and shared across builds:
class _ZetaFooState extends State<ZetaFoo> {
late final FocusNode _focusNode = FocusNode();
late final TextEditingController _controller = TextEditingController();
void dispose() {
_focusNode.dispose();
_controller.dispose();
super.dispose();
}
}
Always dispose controllers and focus nodes in dispose().
Communicating out
Expose callbacks using Flutter's standard callback typedefs. Prefer typed callbacks over Function:
// ✅ Good
final VoidCallback? onTap;
final ValueChanged<bool>? onChanged;
final ValueChanged<String>? onTextChanged;
// ❌ Bad
final Function? onTap;
final Function(dynamic)? onChanged;
Use onTap for taps, onChanged for value-producing interactions, onLongPress for long-press — match Flutter SDK conventions.
Keep render focused
Extract complex sub-trees into private helper methods. Keep build() readable as a structural overview:
Widget build(BuildContext context) {
final colors = Zeta.of(context).colors;
return Column(
children: [
_buildLabel(context, colors),
_buildInput(context, colors),
_buildHint(colors),
].nonNulls.toList(),
);
}
Widget? _buildHint(ZetaColors colors) {
if (hint == null) return null;
return Text(hint!, style: ZetaTextStyles.bodySmall);
}
Conditional rendering
Use if spreads in lists. Avoid SizedBox() as a no-op placeholder — it inserts unnecessary nodes:
// ✅ Good
children: [
if (leadingIcon != null) ZetaIcon(leadingIcon!),
Text(label),
if (trailingIcon != null) ZetaIcon(trailingIcon!),
],
// ❌ Bad
children: [
leadingIcon != null ? ZetaIcon(leadingIcon!) : const SizedBox(),
Text(label),
],
Content and children
Name child parameters after their semantic role, not their position:
/// The leading widget displayed before [label].
final Widget? leading;
/// The trailing widget displayed after [label].
final Widget? trailing;
Do not expose generic child / children unless the component design explicitly shows it as a slot. Use child for a single child, children for multiple children.
Properties
Use @property for values that are part of the component's public API. Reflect to attribute when the value should be queryable via CSS selectors or settable from HTML:
| Scenario | Decorator |
|---|---|
| Attribute in the DOM, queryable via CSS selectors | @property({ type: T, reflect: true }) |
| Property accessible only from JavaScript | @property({ type: T }) |
// Reflected — visible as an HTML attribute
@property({ type: String, reflect: true }) flavor: ButtonFlavor = "primary";
@property({ type: Boolean, reflect: true }) disabled: boolean = false;
// Non-reflected — JavaScript only
@property({ type: String }) label: string = "";
Reactive state
Use @state() for internal reactive values that should not be part of the public API. Lit re-renders when a @state() field changes, just as with @property:
// ✅ Good — internal state, not exposed
@state() private _open: boolean = false;
@state() private _selectedIndex: number = 0;
// ❌ Bad — using a plain field loses reactivity
private _open: boolean = false;
Internal references
Use typed decorator queries instead of imperative shadowRoot.querySelector in lifecycle methods:
@query("button") private readonly buttonEl!: HTMLButtonElement;
@queryAssignedNodes({ slot: "icon" }) private readonly iconNodes!: Node[];
@query runs once and caches the result. Use @queryAll for multiple matches. Never query in render() — always use the decorator form.
Communicating out
Define event detail types in src/events.ts. Always dispatch with composed: true so events cross shadow boundaries:
this.dispatchEvent(
new CustomEvent("zeta-change", {
detail: { value: this.value },
bubbles: true,
composed: true,
}),
);
Document every event with @event in the component's JSDoc so it appears in the generated manifest.
Keep render focused
Extract complex fragments into private render helpers:
protected render() {
return html`
<div class="wrapper">
${this._renderLabel()}
<div class="input contourable-target">
<slot></slot>
</div>
${this._renderHint()}
</div>
`;
}
private _renderLabel(): TemplateResult | typeof nothing {
if (!this.label) return nothing;
return html`<label>${this.label}</label>`;
}
Conditional rendering
Use nothing from lit, not "" or undefined:
// ✅ Good
const icon = this.icon ? html`<zeta-icon>${this.icon}</zeta-icon>` : nothing;
// ❌ Bad — empty string creates a text node
const icon = this.icon ? html`<zeta-icon>${this.icon}</zeta-icon>` : "";
Content and children
Always prefer named slots over child properties — slots allow richer composed content and work natively in HTML. Name slots after their semantic role, not their position. Document every slot with @slot in JSDoc:
<slot></slot>
<!-- default -->
<slot name="icon"></slot>
<slot name="actions"></slot>