Skip to main content

Thinking in Kog

Kog looks like React Native because it is React Native at the surface: TSX, hooks, View/Text/StyleSheet from 'react-native', the component mental model. But the engine underneath uses fine-grained reactivity instead of a virtual DOM. Your component function runs once, at mount; 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's engine.

Two promises​

Kog holds every part of itself to two rules:

  1. React Native is the reference. Component names, props, defaults and APIs follow React Native. Where Kog can't match it, because the display library can't draw something or a microcontroller can't afford it, the difference is written down in Coming from React Native with its reason. There is no CSS, no className and no HTML; each is a compile error that names the React Native replacement.
  2. The engine doesn't defy what you know about JavaScript. A Map lookup or delete is O(1), array indexing is O(1), and iteration is live, the same as in a browser. Anything that can't work fails loudly with a message that tells you the fix, instead of silently doing nothing.

The differences on this page are the allowed kind: they change how your program runs, not what a correct program computes.

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 most common slip from useState habits 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 once, at mount. It is not "the render function": there is no render loop to re-run it. Derived values stay fresh because the compiler analyzes your derivations. A binding is kept live automatically when it is computed purely from state, props, a @kog/router location hook (const { pathname } = useLocation()), or another derived binding, and something reactive reads it. State/props derivations use one memo when read in several places and an inlined recompute when read once; direct router-hook derivations retain an owner-bound memo so they also work from native event callbacks. So const passed = results.filter(r => r.ok).length tracks results exactly as it would in React, with no annotation.

When the compiler cannot safely keep a binding live, it fails the build with an error that names the binding, instead of letting it freeze silently. Two kinds of binding trigger this: initializers it can't prove side-effect-free (re-running a log, a mutation, or an unknown call behind your back would change what your program does), and variables assigned imperatively (let status; if (winner) status = …; write the expression form, or use useMemo). Two positions intentionally keep their mount-time snapshot, because that's React's own semantics: useState/useRef/useReducer initial values and useEffect(fn, []) bodies. And bare side effects in the body still run exactly once — put reactions in useEffect.

Upgrading from an earlier Kog: pure body derivations used to freeze silently at their mount-time value; they are now live. If your code wanted the mount snapshot, say so explicitly with useState(() => expr)[0], and it reads as intent instead of an accident.

4. Effects can't see stale values, and an effect without deps tracks itself​

useEffect(fn, [a, b]) follows React: it re-runs only when a or b changes (Object.is), and [] means mount-only. What differs is staleness. State reads inside the effect always return the current value, so a dependency you forgot to list never hands you an old one; it only means a change to it doesn't re-run the effect. Leave the array off and the effect is auto-tracked: it re-runs when any reactive value it reads changes. The exhaustive-deps lint is a warning about when the effect re-runs, not about stale reads. useMemo's deps array is still ignored, because the memo tracks its own reads.

The same goes for an effect's cleanup: it reads state live, so when the effect re-runs, the cleanup sees the new value, where React's cleanup sees the value from the render that created it. If the cleanup needs the old value, copy it into a const inside the effect.

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

Nothing re-runs a component body, and nothing double-invokes effects: a mount-only effect runs once at mount and its 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 doesn't re-run your component (nothing does). A provider's value is read once at mount, so for a context that changes, provide an accessor (value={() => theme}) and call it where you read it. Then 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 <ActivityIndicator/> and if (!visible) return null are fine at the top of the component, before other logic, and they stay reactive: the component shows or hides as the condition changes. An early return buried mid-function is a compile error; the error message points you to a ternary or a top-level guard.
  2. Text children are text. <Text> accepts strings, numbers, and expressions, but not nested elements (LVGL labels are leaf widgets). The compiler rejects them. Values render as in React: false, true, null, and undefined show nothing, so {count > 0 && count} is empty at zero.

Both are enforced by the compiler, with messages that show the fix. @kog/eslint-plugin (preconfigured in the template) also flags reactive early returns as you type.

What you never think about​

  • When to memoize: never for performance. A bare const derived from state is kept live by the compiler (divergence #3); useMemo is the same derivation spelled explicitly, and the compiler's suggested fix when an initializer isn't provably pure. useCallback is free (identity is stable by construction). 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 (without one, rows are keyed by index); everything else mounts once.