Skip to main content

Thinking in Kog

Kog looks like React because it is React at the surface: TSX, hooks, the component mental model. But the engine underneath is fine-grained reactivity, not a virtual DOM — your component function runs once, at mount, and afterward only the specific expressions that depend on changed state re-execute.

This is almost always what you wanted React to do anyway. But it produces six observable differences. Learn these and you know everything unusual about Kog.

The six divergences

1. No stale closures

Event handlers always read the current value of state. count inside onPress evaluates when the handler runs, not when the component "rendered" (there is no render to capture it).

const [count, setCount] = useState(0);
// In React this logs the count from render time. In Kog it logs the count *now*.
<Pressable onPress={() => console.log(count)} />

The functional-update form setCount(c => c + 1) still works — it's just no longer required for correctness.

2. Reads after setState see the new value

State writes are synchronous; the UI flush is batched to a microtask.

setCount(1);
console.log(count); // 1 in Kog (0 in React)

The gotcha: the #1 useState-habit slip is reading a value after setting it and expecting the old one — setCount(n); log(count + 1) logs n + 2, not n + 1, because count is already n. Capture the value up front: const next = count + 1; setCount(next); log(next);.

3. The component body runs once

Code directly in the component body executes at mount, once. It is not "the render function." Side effects in the body don't re-run when state changes — put reactions in useEffect.

4. Dependency arrays are honored but can't cause staleness

useEffect(fn, [a, b]) re-runs the effect when a or b change, as in React. But a missing dependency never gives you a stale value (see #1) — so the exhaustive-deps lint is a warning about clarity, not a bug alarm.

5. No re-renders, no StrictMode double-invoke

Effects run once at mount, cleanup once at unmount. There is no development-mode double invocation, because the failure mode it guards against (impure renders) doesn't exist.

6. Context updates are fine-grained

Consuming a context that changes doesn't re-run your component (nothing does). Only the expressions that read the context value update.

Two structural rules

The compiler needs your JSX to be statically analyzable in two places:

  1. Early returns must be guards. if (loading) return <Spinner/> is fine at the top of the component, before other logic. A reactive early return buried mid-function is a compile error — the error message will point you to a ternary or explicit conditional rendering.
  2. Text children are text. <Text> accepts strings, numbers, and expressions — not nested elements (LVGL labels are leaf widgets). The linter flags it.

Both are enforced by @kog/eslint-plugin (preconfigured in the template) with messages that show the fix.

What you never think about

  • When to memoizeuseCallback is free (identity is stable by construction) and useMemo is a real derived value, not a render-time cache hint. React.memo has no meaning; there's nothing to skip.
  • Render performance — there are no renders. A slider updating 60×/second executes one native setter 60×/second, regardless of how big your component tree is.
  • Keys for static lists.map() over changing data does want keys (the linter reminds you); everything else just mounts once.