npm Libraries#

A big reason to write UI in TypeScript is the npm ecosystem: state management, data tables, validation, forms, dates. Much of it runs unmodified in OneJS. This page explains which kinds of packages work, lists popular ones we have verified, and shows you how to evaluate any library yourself.

Installing a Package#

Your app's working directory is a normal npm package. Install a library, import it, and the build bundles it:

cd Assets/Scenes/MainScene/MyApp~
npm install zustand
import { create } from "zustand"

There is no separate registration step. esbuild bundles everything your entry file imports, including node_modules, into the single app.js.txt that OneJS runs.

The Compatibility Rule#

OneJS gives you a real JavaScript engine and real React 19, but not a browser. There is no DOM: React renders to UI Toolkit elements, and on native platforms window and document do not exist.

That one fact decides almost everything:

  • Libraries that compute work. State management, table and row models, schema validation, dates, immutable updates, state machines, general utilities. These are often called headless: they hold data and logic and let you render the results. They never name a DOM node, so nothing is missing for them.
  • Libraries that render do not work. Component kits and animation libraries produce <div> and <span> elements through react-dom, which has no meaning here. No amount of configuration changes that.

You can usually classify a package before installing it:

npm view some-package peerDependencies

A required react-dom peer dependency means the package renders HTML and will not work. No react-dom requirement is a strong positive signal, and so is a library advertising server-side rendering support or React Native support, since both mean it already tolerates a missing window.

Do not trust names: @headlessui/react requires react-dom and renders HTML elements. The "headless" in its name refers to styling, not rendering.

Verified Libraries#

Each of these ran in the OneJS runtime with its real API paths exercised: state updates re-rendering components, queries resolving through the scheduler, validation accepting and rejecting input.

LibraryVerifiedNotes
zustand5.xWorks as is
jotai2.xWorks as is
xstate + @xstate/react5.x / 6.xWorks as is
@tanstack/react-table9.xWorks as is, see the example below
@tanstack/react-query5.xOne setIsServer line, see Case Study
react-hook-form7.xUse the Controller API. register() spreads DOM input props and does not apply here
zod4.xWorks as is
immer11.xWorks as is
date-fns4.xWorks as is
lodash-es4.xWorks as is

This list is not exhaustive and never will be: it is a sample of popular packages we have tested. The rule above matters more than the list. A pure data or logic library you do not see here will very likely work, and the sections below show how to confirm it in minutes.

Known DOM-bound packages that will not work, each with a required react-dom peer dependency: @mui/material, @radix-ui/react-*, @headlessui/react, framer-motion, styled-components, ag-grid-react. For ready-made components, use onejs-ui instead. To wrap your own C# controls as React components, see Custom Elements.

What the Runtime Defines#

Libraries probe their environment, so it helps to know what is there. On native platforms (desktop, mobile):

AvailableNot defined
fetch, WebSocketwindow, document, navigator
localStorage, sessionStorageTextEncoder, crypto
AbortController, AbortSignal (OneJS 3.1.3+)Intl
setTimeout, setInterval, queueMicrotaskWorker, WebAssembly
requestAnimationFrame, performance.now()
URL, URLSearchParams, btoa, atob
Proxy and the standard built-ins (Promise, Map, Set, and friends)

On WebGL the picture changes: your code runs in the browser's own JavaScript engine, so window, document, and everything else the browser defines are real. The same bundle can therefore take a library's browser code path in a WebGL build and its fallback path on desktop and mobile. Web APIs: Browser Detection in Libraries covers this in detail.

Example: A Data Grid with TanStack Table#

The most common ask beyond basic components is a proper data grid: sorting, filtering, column definitions. TanStack Table is the standard headless answer on the web, and it runs unmodified in OneJS. The library builds the row model; you render rows with OneJS components:

import { useState } from "react"
import { ScrollView, Text, TextField, View } from "onejs-react"
import {
    columnFilteringFeature, createColumnHelper, createFilteredRowModel,
    createSortedRowModel, filterFn_includesString, rowSortingFeature,
    tableFeatures, useTable,
} from "@tanstack/react-table"

type Member = { name: string, role: string, level: number }

const features = tableFeatures({
    rowSortingFeature,
    sortedRowModel: createSortedRowModel(),
    columnFilteringFeature,
    filteredRowModel: createFilteredRowModel(),
    filterFns: { includesString: filterFn_includesString },
})

const helper = createColumnHelper<typeof features, Member>()
const columns = helper.columns([
    helper.accessor("name", { header: "Name" }),
    helper.accessor("role", { header: "Role" }),
    helper.accessor("level", { header: "Level" }),
])

export function RosterTable({ data }: { data: Member[] }) {
    const [filter, setFilter] = useState("")
    const table = useTable({ features, columns, data })

    const onFilter = (value: string) => {
        setFilter(value)
        table.getColumn("name")?.setFilterValue(value)
    }

    return (
        <View style={{ flexGrow: 1, padding: 16 }}>
            <TextField value={filter} placeholder="Filter by name"
                onChange={(e) => onFilter(e.value)} />
            <View style={{ flexDirection: "row" }}>
                {table.getHeaderGroups()[0].headers.map((header) => (
                    <View key={header.id} style={{ width: 140 }}
                        onClick={() => header.column.toggleSorting()}>
                        <Text>{String(header.column.columnDef.header)}</Text>
                    </View>
                ))}
            </View>
            <ScrollView style={{ flexGrow: 1 }}>
                {table.getRowModel().rows.map((row) => (
                    <View key={row.id} style={{ flexDirection: "row" }}>
                        {row.getAllCells().map((cell) => (
                            <Text key={cell.id} style={{ width: 140 }}>
                                {String(cell.getValue())}
                            </Text>
                        ))}
                    </View>
                ))}
            </ScrollView>
        </View>
    )
}

Three details specific to Table v9:

  • Features are opt-in. tableFeatures({}) is core only; list sorting and filtering along with their row-model slots as above.
  • Filter functions are registered explicitly (the filterFns entry). A missing one fails silently outside a development build.
  • Call column.toggleSorting() directly. The getToggleSortingHandler() helper expects to be handed a DOM event.

For lists with thousands of rows, pair the table's row model with ListView, which virtualizes rendering.

Testing a Library Quickly#

Before building on a library, spend two minutes confirming it in the runtime. Exercise a real code path and log the output; a successful import proves very little, because a library with browser fallbacks can load fine and still quietly lose features:

import { format } from "date-fns"
import { z } from "zod"

console.log(format(new Date(2026, 0, 15), "yyyy-MM-dd"))
console.log(z.object({ n: z.number() }).safeParse({ n: 1 }).success)

Drop this in a scratch file in your app, or use JSPad for a throwaway test without touching your project. The output lands in the Unity Console.

When a Library Misbehaves#

Failures come in three shapes, from loudest to quietest:

  1. Hard failure. A ReferenceError names the missing global directly. If the missing piece is a DOM API, the library is DOM-bound and out of scope. If it is a small standard primitive, you can polyfill it (see below).
  2. Silent degradation. The library runs but a documented feature quietly does nothing, because a typeof window check routed it to a fallback. The supabase-js session persistence example in Web APIs is this shape, and the fix is usually an option the library provides for injecting the dependency explicitly.
  3. Silent hang. An async operation never completes and never errors. The case study below is this shape.

For shapes 2 and 3, the diagnostic is the same: find the library's environment checks and read what they gate.

grep -rn "typeof window" node_modules/some-package/dist | head

Case Study: TanStack Query#

A textbook silent hang, worth walking through because the diagnosis transfers to other libraries. On OneJS 3.1.2 and older, useQuery stays pending forever: no data, no error, nothing in the console.

Grepping its source turns up two separate browser assumptions:

  1. It constructs a new AbortController() for every fetch to provide the query's cancellation signal. Older runtimes did not define AbortController, so the fetch died mid-flight with nothing surfaced. OneJS 3.1.3+ ships the primitive, so this half fixes itself; on older versions, install the shim below.
  2. Its isServer check treats any runtime without window as a server doing server-side rendering. That zeroes the default retries and suppresses staleness timers and refetchInterval. This one applies on every OneJS version, and the fix is the library's own override:
import { environmentManager } from "@tanstack/react-query"

// OneJS is a client runtime without a window, not a server
environmentManager.setIsServer(() => false)

The shim for OneJS 3.1.2 and older (harmless on newer versions, it installs only when the primitive is missing):

// Run once, before creating your QueryClient
if (typeof (globalThis as any).AbortController === "undefined") {
    class AbortSignalShim {
        aborted = false
        reason: unknown = undefined
        listeners: (() => void)[] = []
        addEventListener(type: string, cb: () => void) { if (type === "abort") this.listeners.push(cb) }
        removeEventListener(_type: string, cb: () => void) { this.listeners = this.listeners.filter(l => l !== cb) }
        throwIfAborted() { if (this.aborted) throw this.reason }
    }
    class AbortControllerShim {
        signal = new AbortSignalShim()
        abort(reason?: unknown) {
            if (this.signal.aborted) return
            this.signal.aborted = true
            this.signal.reason = reason ?? new Error("Aborted")
            this.signal.listeners.forEach(l => l())
        }
    }
    ;(globalThis as any).AbortController = AbortControllerShim
    ;(globalThis as any).AbortSignal = AbortSignalShim
}

With that in place, queries resolve, retries behave like a client, and refetchInterval fires. All verified in the runtime.

Note what the fix is not: it does not define window or document. Polyfilling a specific standard primitive like AbortController is safe because nothing uses it to detect a browser. Defining fake browser globals flips every library's environment detection into the browser branch at once, and things break in far worse ways. The same rule appears in Web APIs, and it is the single most important habit for working with third-party packages in OneJS.