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 arrayFor 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
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.
| Scenario | Per-read cost |
|---|---|
| Unregistered property | ~2 string allocs + reflection + boxing |
| FastPath-registered property | Zero managed allocations |
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 250msDependencies#
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 }
}
}