DraftThis article is currently in draft mode

DevState: Extensible Debug Renderer

October 21, 2025

DevState: Extensible Debug Renderer#

Render complex runtime data structures with type-specific handlers. Register custom renderers to format atoms, queries, and domain objects without touching the core.

Registry Pattern#

Define custom renderers with type guards:

export function defineRenderer<T extends UnknownObject>(
  test: (data: UnknownObject) => data is T,
  render: Renderer<T>,
): RegistryItem<any> {
  return { test, render };
}
// Match by type predicate
defineRenderer(
  (data) => isAtom(data),
  ({ data, showPath, r }) => {
    const value = useAtomValue(data);
    return <r.Unknown name={data.toString()} data={value} showPath={showPath} />;
  },
);

String-based renderers parse and transform:

export function defineStringRenderer<T>(
  parse: (data: string) => null | undefined | T,
  render: Renderer<T>,
): StringRegistryItem<any> {
  return { parse, render };
}
// Parse UIDs from strings
defineStringRenderer(
  (data) => (World.isUID(data) ? data : null),
  ({ data, r, showPath }) => (
    <r.Custom rawValue={data} icon={Ta.IconBox} showPath={showPath}>
      <UIDElement uid={data} />
    </r.Custom>
  ),
);

Renderer Components#

Built-in primitives handle common cases:

const r: RendererComponents = {
  Array: <Array data={items} name="Items" />,
  Object: <Object data={obj} name="Config" commaSeparatedOmitKeys="internal,_private" />,
  Unknown: <Unknown data={value} name="Result" />,
  Placeholder: <Placeholder name="WeakMap" />,
  Custom: (
    <Custom icon={Ta.Icon} name="Type">
      ...
    </Custom>
  ),
};

Use commaSeparatedOmitKeys to filter object keys without memoizing the data object.

Integration#

Register renderers at app root:

export const ProvideAppDevStateRenderers = ({ children }: { children: React.ReactNode }) => {
  return (
    <ProvideDevStateRenderers registry={RENDERERS} stringRegistry={STRING_RENDERERS} orderedKeys={KEY_ORDER_PREFERENCE}>
      {children}
    </ProvideDevStateRenderers>
  );
};
const RENDERERS: RegistryItem<any>[] = [
  defineRenderer(
    (thing) => thing instanceof DevString,
    ({ data, r }) => <r.Custom name="DevString">...</r.Custom>,
  ),
  defineRenderer(
    objectPredicateMacro<Queryable>(["label", "hash"], (d) => d._tag === "db"),
    ({ data, r }) => <LiveQueryRenderer query={data} r={r} />,
  ),
];

export const ProvideAppDevStateRenderers = ({ children }) => (
  <ProvideDevStateRenderers
    registry={RENDERERS}
    stringRegistry={STRING_RENDERERS}
    orderedKeys={["devInfo", "name", "source"]}
  >
    {children}
  </ProvideDevStateRenderers>
);

Real Examples#

Jotai Atoms: Read atom value and render live

defineRenderer(
  (thing) => isAtom(thing),
  ({ data, r }) => {
    const value = useAtomValue(data);
    return <r.Unknown name={data.debugLabel} data={value} />;
  },
);

DevString with location links:

defineRenderer(
  (thing) => thing instanceof DevString,
  ({ data, r, showPath }) => {
    const { context, message } = data.toJSON();
    return (
      <r.Custom name="DevString" nameChild={<DevStringLink reason={data} />}>
        {message.map((part, i) =>
          typeof part === "string" ? <span>{part}</span> : <r.Unknown data={part} showPath={["msg"]} />,
        )}
        {data.cause && <r.Unknown name="Reason" data={data.cause} />}
      </r.Custom>
    );
  },
);

Key Features#

  • Type-safe matching: Type guards ensure correct data shape
  • Composable: Renderers use r.* components to recurse
  • React hooks: Use atoms, queries, state inside renderers
  • Depth tracking: Automatic collapse at configurable depth
  • Circular detection: Prevents infinite recursion
  • Ordered keys: Control property display order globally