Controllable signal primitive

Implements the controlled/uncontrolled state primitive with full TDD coverage (5 tests passing). Delegates to an internal signal when uncontrolled, defers to the external value accessor when controlled.
This commit is contained in:
Mats Bosson 2026-03-29 02:38:40 +07:00
parent db906fd85a
commit 8f2e6042ea
2 changed files with 103 additions and 0 deletions

View File

@ -0,0 +1,36 @@
import { type Accessor, createSignal } from "solid-js";
export interface CreateControllableSignalOptions<T> {
/** Returns the controlled value, or undefined if uncontrolled. */
value: Accessor<T | undefined>;
/** Default value used when uncontrolled. */
defaultValue: Accessor<T>;
/** Called whenever the value changes (both modes). */
onChange?: (value: T) => void;
}
/**
* Handles controlled vs uncontrolled state for any stateful component.
* When `value()` is not undefined, the component is controlled the external
* value is the source of truth. Otherwise, an internal signal manages state.
*/
export function createControllableSignal<T>(
options: CreateControllableSignalOptions<T>,
): [Accessor<T>, (value: T) => void] {
const [internalValue, setInternalValue] = createSignal<T>(options.defaultValue());
const get: Accessor<T> = () => {
const controlled = options.value();
return controlled !== undefined ? controlled : internalValue();
};
const set = (value: T) => {
const isControlled = options.value() !== undefined;
if (!isControlled) {
setInternalValue(() => value);
}
options.onChange?.(value);
};
return [get, set];
}

View File

@ -0,0 +1,67 @@
import { createRoot, createSignal } from "solid-js";
import { describe, expect, it, vi } from "vitest";
import { createControllableSignal } from "../../src/primitives/create-controllable-signal";
describe("createControllableSignal", () => {
it("uses defaultValue when value accessor returns undefined (uncontrolled)", () => {
createRoot((dispose) => {
const [get] = createControllableSignal({
value: () => undefined,
defaultValue: () => false,
});
expect(get()).toBe(false);
dispose();
});
});
it("uses value accessor when provided (controlled)", () => {
createRoot((dispose) => {
const [get] = createControllableSignal({
value: () => true,
defaultValue: () => false,
});
expect(get()).toBe(true);
dispose();
});
});
it("calls onChange when setter is called in uncontrolled mode", () => {
createRoot((dispose) => {
const onChange = vi.fn();
const [, set] = createControllableSignal({
value: () => undefined,
defaultValue: () => false,
onChange,
});
set(true);
expect(onChange).toHaveBeenCalledWith(true);
dispose();
});
});
it("updates internal signal when setter called in uncontrolled mode", () => {
createRoot((dispose) => {
const [get, set] = createControllableSignal({
value: () => undefined,
defaultValue: () => "initial",
});
set("updated");
expect(get()).toBe("updated");
dispose();
});
});
it("does not update internal signal in controlled mode — external value is source of truth", () => {
createRoot((dispose) => {
const [externalValue] = createSignal<string>("controlled");
const [get, set] = createControllableSignal({
value: externalValue,
defaultValue: () => "default",
});
set("ignored");
// Still reflects external value since we're controlled
expect(get()).toBe("controlled");
dispose();
});
});
});