C# State Sync#

React doesn't know when C# values change. OneJS provides hooks that poll C# state every frame and re-render only when values differ.

import { useFrameSync, toArray } from "onejs-react"

Simple Mode#

For primitives (numbers, strings, booleans) — pass a getter function. Re-renders when the value changes (Object.is comparison):

const health = useFrameSync(() => player.Health)
const gold = useFrameSync(() => player.Gold)
const name = useFrameSync(() => target?.name ?? "Unknown")

Selector Mode#

C# objects always return the same proxy reference (cached by handle), so Object.is would always return true. Pass a selector as the second argument to extract comparable primitives:

const place = useFrameSync(
    () => gameState.currentPlace,
    (p) => [p?.Name, p?.NPCs?.Count]
)

Each frame, the selector's output is compared element-by-element. If any value changed, the component re-renders. The returned object is always fresh from the getter.

This works with any C# object — game state, Unity structs, quest data:

// Structs: extract the fields you care about
const pos = useFrameSync(
    () => player.transform.position,
    (p) => [p?.x, p?.y, p?.z]
)

// Version stamp: catch any change with a single selector
const quest = useFrameSync(
    () => questManager.activeQuest,
    (q) => [q?.Version]
)

Collections and the Parent/Child Pattern#

C# collections (List<T>, arrays) have .Count and indexers but no .map() or .filter(). Use toArray to convert them to JS arrays:

const items = toArray(inventory) // reads .Count, loops indexer, returns JS array

For lists with mutable items (inventories, NPC lists, quest logs), split into a parent that watches list structure and children that each watch their own item:

  • Parent selects [collection.Count] — re-renders only on add/remove
  • Child selects [item.Name, item.Durability, ...] — re-renders only when that item changes
When one item's durability drops, only that child re-renders. The parent and all other children are untouched. When an item is added or removed, the parent re-renders and mounts/unmounts the affected child.

Full Example#

Here's a complete working example showing all three patterns together: primitives, selectors, and the parent/child collection pattern.

C# component:

using System.Collections.Generic;
using UnityEngine;

namespace MyGame {
    public class Item {
        public int Id { get; set; }
        public string Name { get; set; }
        public int Durability { get; set; }
        public int StackCount { get; set; }
        public int Version { get; set; }
    }

    public class PlayerController : MonoBehaviour {
        public int Health { get; set; } = 100;
        public int Gold { get; set; } = 50;
        public List<Item> Inventory { get; set; } = new() {
            new Item { Id = 1, Name = "Sword", Durability = 100, StackCount = 1, Version = 1 },
            new Item { Id = 2, Name = "Shield", Durability = 80, StackCount = 1, Version = 1 },
            new Item { Id = 3, Name = "Potion", Durability = 1, StackCount = 5, Version = 1 },
        };
    }
}
Use C# properties ({ get; set; }) rather than plain fields. The QuickJS proxy resolves properties via reflection.

TypeScript UI:

import React from "react"
import { View, Text, ScrollView, render } from "onejs-react"
import { useFrameSync, toArray } from "onejs-react"

interface Item {
    Id: number
    Name: string
    Durability: number
    StackCount: number
    Version: number
}

interface PlayerController {
    Health: number
    Gold: number
    Inventory: { Count: number; [index: number]: Item }
}

// Cache reference at module scope — don't call Find() inside a getter
const player = CS.UnityEngine.GameObject.Find("Player")
    ?.GetComponent("MyGame.PlayerController") as unknown as PlayerController | null

// --- Child: only re-renders when THIS item's properties change ---
const ItemSlot = React.memo(function ItemSlot({ item }: { item: Item }) {
    const data = useFrameSync(
        () => item,
        (i) => [i.Name, i.Durability, i.StackCount]
    )

    return (
        <View style={{ flexDirection: "row", padding: 4 }}>
            <Text style={{ color: "#fff" }}>
                {data.Name} x{data.StackCount} ({data.Durability} dur)
            </Text>
        </View>
    )
})

// --- Parent: only re-renders when items are added/removed ---
function InventoryPanel() {
    const inv = useFrameSync(
        () => player?.Inventory ?? null,
        (i) => [i?.Count]
    )

    if (!inv) return <Text style={{ color: "#666" }}>No inventory</Text>

    return (
        <ScrollView>
            {toArray<Item>(inv).map(item => (
                <ItemSlot key={item.Id} item={item} />
            ))}
        </ScrollView>
    )
}

// --- App: primitives use simple mode ---
function App() {
    const health = useFrameSync(() => player?.Health ?? 0)
    const gold = useFrameSync(() => player?.Gold ?? 0)

    return (
        <View style={{ padding: 16, backgroundColor: "#1a1a1a" }}>
            <Text style={{ color: "#4ade80", fontSize: 18 }}>HP: {health}</Text>
            <Text style={{ color: "#fbbf24", fontSize: 18 }}>Gold: {gold}</Text>
            <Text style={{ color: "#888", marginTop: 16, marginBottom: 8 }}>
                Inventory:
            </Text>
            <InventoryPanel />
        </View>
    )
}

render(<App />, __root)

If your C# items use a version stamp (e.g., via Fody), the child selector can be simplified to (i) => [i.Version] to catch any property change.

This pattern scales to any nested structure — places with NPCs, quest logs with objectives, skill trees with nodes.

Performance#

useFrameSync polls C# properties every frame via the standard CS proxy. By default, each property read allocates managed memory (string marshaling, reflection, boxing of value types). With several properties at 60fps, this adds up.

OneJS has a FastPath system that intercepts these calls and handles them with zero allocation. Common Unity types (Transform, Time, GameObject, etc.) are pre-registered. For your own types, register properties in C#:

// Register once (e.g., in Awake or RuntimeInitializeOnLoadMethod)
QuickJSNative.FastPath.Property<PlayerController, int>("Health", p => p.Health);
QuickJSNative.FastPath.Property<PlayerController, int>("Gold", p => p.Gold);
QuickJSNative.FastPath.Property<PlayerController, float>("Speed", p => p.Speed, (p, v) => p.Speed = v);

No JavaScript changes needed — the same useFrameSync(() => player.Health) call now hits the fast path automatically.

ScenarioPer-read cost
Unregistered property~2 string allocs + reflection + boxing
FastPath-registered propertyZero managed allocations
For most UIs, the default reflection path is fine. Register FastPath properties when you see GC pressure in the Unity Profiler, or for properties polled at high frequency (per-frame with many consumers).

See Zero-Allocation Interop for the full details on FastPath registration and the za API.

Reference#

useThrottledSync#

Polls at a custom interval instead of every frame:

import { useThrottledSync } from "onejs-react"

const gameTime = useThrottledSync(() => gameManager.GameTime, 1000)  // every 1s
const score = useThrottledSync(() => gameManager.Score, 250)         // every 250ms

Dependencies#

Both modes accept an optional deps array as the last argument, for when the getter's source reference can change:

const health = useFrameSync(() => currentPlayer.Health, [currentPlayer])

const place = useFrameSync(
    () => currentPlayer.Location,
    (loc) => [loc?.Name],
    [currentPlayer]
)

TypeScript Declarations#

Define interfaces for your C# types in a .d.ts file:

// types/game.d.ts
declare namespace CS.MyGame {
    interface Item {
        readonly Id: number
        readonly Name: string
        readonly Durability: number
        readonly StackCount: number
        readonly Version: number
    }

    interface PlayerController {
        readonly Health: number
        readonly Gold: number
        readonly Inventory: { Count: number; [index: number]: Item }
    }
}