Use UI Foundation headlessly
Drive @tetherto/mdk-ui-foundation stores from any runtime without React
Overview
@tetherto/mdk-ui-foundation is the framework-agnostic headless layer of the MDK App Toolkit. This page
walks through installing it on its own and driving its Zustand stores from a non-React runtime: a Node script, a Vue or
Svelte adapter you're authoring, a CLI tool, or a test helper.
When to reach for this
Use headless UI Foundation when:
- You're authoring a framework adapter (Vue, Svelte, Web Components) and need raw access to the Zustand stores
- You're building a Node CLI or backend service that has to read MDK telemetry and act on it
- You're writing test helpers or fixtures that need to seed and inspect store state without a React renderer
- You need to subscribe to store changes from non-UI code, such as logging, websocket bridges or metrics
For a React app, the React adapter wraps UI Foundation with <MdkProvider> and adapter hooks. Use that
path instead so most React code never touches @tetherto/mdk-ui-foundation directly.
Install
@tetherto/mdk-ui-foundation has no peer dependencies on React or any UI framework. It is ESM-only, so mark your project as
a module:
npm install @tetherto/mdk-ui-foundation
npm pkg set type=moduleSubpath imports
Pull only the pieces you need from the relevant subpath. Subpath imports give tree-shakers a smaller surface than the top-level barrel:
import { authStore, devicesStore } from '@tetherto/mdk-ui-foundation/store'
import { createMdkQueryClient } from '@tetherto/mdk-ui-foundation/query'
import type { Rack } from '@tetherto/mdk-ui-foundation/types'These are the subpath entries most apps need. The package also publishes /app-shared-modules, /app-ui-paths and
/stores.json. Every entry loads in plain Node and typechecks under both "moduleResolution": "nodenext" and "bundler".
Create a QueryClient
createMdkQueryClient returns a TanStack Query Core client wired to your Gateway. Pass an explicit apiBaseUrl, or let the
factory resolve one from environment variables:
import { createMdkQueryClient } from '@tetherto/mdk-ui-foundation/query'
const queryClient = createMdkQueryClient({
apiBaseUrl: 'https://gateway.example.com',
})Without an explicit apiBaseUrl, the factory checks VITE_MDK_API_URL, then MDK_API_URL, then the deprecated
VITE_API_BASE_URL / API_BASE_URL (which log a one-time warning), before falling back to http://localhost:3000.
Passing '' is not the same as passing nothing: it means "use relative URLs", which is what you want when the Gateway
serves the UI.
Bring your own backend
createMdkQueryClient accepts a fetcher, the seam for pointing the query layer at a different transport: a custom auth
scheme, a different protocol, or fixtures served from memory for a server-less demo.
const queryClient = createMdkQueryClient({
apiBaseUrl,
fetcher: myFetcher,
})The resolved base URL and that fetcher are stashed on the client's query and mutation meta, so everything reading through
the client picks them up.
createMdkQueryClient takes no endpoints option and ships no prebuilt query or mutation factories. To read a route
your Gateway app serves, call it with useQuery directly.
Read store state
Each store is a Zustand vanilla singleton. getState() returns the current snapshot:
import { authStore } from '@tetherto/mdk-ui-foundation/store'
const { token, permissions } = authStore.getState()
console.log('current token', token)Write store state
setState() accepts either a partial object or a function that receives the previous state:
import { devicesStore } from '@tetherto/mdk-ui-foundation/store'
devicesStore.setState({ filterTags: ['rack-a'] })
devicesStore.setState((prev) => ({
selectedDevices: [...prev.selectedDevices, newDevice],
}))Subscribe to changes
subscribe() runs a callback on every state change and returns an unsubscribe function:
import { notificationStore } from '@tetherto/mdk-ui-foundation/store'
const unsubscribe = notificationStore.subscribe((state) => {
console.log('unread notifications:', state.count)
})
unsubscribe()A complete Node example
A small Node script that signs itself in with a token you already hold, reads a route your Gateway app serves, and prints
the unread notification count. A Gateway serves exactly the routes its declared apps provide, so substitute your own path
for /site/devices:
import { createMdkQueryClient } from '@tetherto/mdk-ui-foundation/query'
import {
authStore,
devicesStore,
notificationStore,
} from '@tetherto/mdk-ui-foundation/store'
const apiBaseUrl = process.env.MDK_API_URL ?? 'http://localhost:3000'
const main = async (): Promise<void> => {
const queryClient = createMdkQueryClient({ apiBaseUrl })
authStore.setState({ token: process.env.MDK_TOKEN ?? '' })
const devices = await queryClient.fetchQuery({
queryKey: ['devices', 'list'],
queryFn: async () => {
const res = await fetch(`${apiBaseUrl}/site/devices`, {
headers: { Authorization: `Bearer ${authStore.getState().token}` },
})
if (!res.ok) {
throw new Error(`ERR_DEVICES_REQUEST: ${res.status}`)
}
return res.json()
},
})
devicesStore.getState().setSelectedDevices(devices)
console.log(`Found ${devices.length} devices`)
console.log(`unread notifications: ${notificationStore.getState().count}`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})Save it as script.ts in the module project from Install and run it. Node 22.18 or later strips the type
annotations itself:
MDK_TOKEN=ey... MDK_API_URL=https://gateway.example.com node script.tsQuery helpers lists every helper, including the resolveApiBaseUrl resolution order.
Next steps
- Stores: every store with its state fields and actions
- Query helpers:
createMdkQueryClient,resolveApiBaseUrland the client accessors - What's an app?: where the UI devkit fits into an MDK app's anatomy
- React adapter: if you decide to layer React on top