Use UI Foundation headlessly
Drive @tetherto/mdk-ui-foundation stores from any runtime without React
@tetherto/mdk-ui-foundation
@tetherto/mdk-ui-foundation is the framework-agnostic headless layer of the MDK App Toolkit. This how-to 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 — logging, websocket bridges, 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.
npm install @tetherto/mdk-ui-foundationSubpath 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 { Device } from '@tetherto/mdk-ui-foundation/types'These are the supported subpath entries — /store, /query, and /types.
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://app-node.example.com',
})Without an explicit apiBaseUrl, the factory checks VITE_MDK_API_URL then MDK_API_URL before falling back to http://localhost:3000.
Bring your own backend
createMdkQueryClient also accepts fetcher and endpoints, the seam for pointing the query layer at a backend other than the mining Gateway:
fetcherswaps the transport: pass aFetcherthat talks to a custom auth scheme, a different protocol, or serves fixtures from memory for a server-less demo.endpointsremaps the:namepath templates the factories request, pointing the same factories and adapter hooks at a different API's URL space.
const queryClient = createMdkQueryClient({
apiBaseUrl,
endpoints: MY_ENDPOINTS,
fetcher: myFetcher,
})Both are stashed on the client's query and mutation meta and read back by every query and mutation factory, so the same factories and adapter hooks work unchanged regardless of which backend is behind them.
[!TIP] The catalog app's
bring-your-own-backendexample takes this further: it skips the query layer entirely and drives the same devkit components from plain TanStackuseQueryagainst a foreign API shape, useful when a backend doesn't fit thefetcher/endpointsseam at all.
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({ selectedDeviceId: 'wm-002' })
devicesStore.setState((prev) => ({
devices: [...prev.devices, 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 authenticates against the Gateway, fetches the device list once, and then tails unread notification count changes:
import { createMdkQueryClient } from '@tetherto/mdk-ui-foundation/query'
import {
authStore,
devicesStore,
notificationStore,
} from '@tetherto/mdk-ui-foundation/store'
async function main() {
const queryClient = createMdkQueryClient({
apiBaseUrl: process.env.MDK_API_URL ?? 'http://localhost:3000',
})
authStore.setState({ token: process.env.MDK_TOKEN ?? '' })
const devices = await queryClient.fetchQuery({
queryKey: ['devices', 'list'],
queryFn: async () => {
const res = await fetch(`${process.env.MDK_API_URL}/api/devices`, {
headers: { Authorization: `Bearer ${authStore.getState().token}` },
})
return res.json()
},
})
devicesStore.setState({ devices })
console.log(`Found ${devices.length} devices`)
const unsubscribe = notificationStore.subscribe((state) => {
console.log(`unread notifications: ${state.count}`)
})
process.on('SIGINT', () => {
unsubscribe()
process.exit(0)
})
}
main().catch((err) => {
console.error(err)
process.exit(1)
})Run it with:
MDK_TOKEN=ey... MDK_API_URL=https://app-node.example.com node script.tsFor the prebuilt query and mutation factories (authQuery, devicesQuery, deviceQuery, telemetryQuery), check the UI reference.
Next steps
- UI reference: full store list, query helpers, and the
createMdkQueryClientresolution order. - 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.