MDK Logo

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-foundation

Subpath 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.

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.

[!NOTE] Earlier releases also shipped an endpoints option and a set of prebuilt query and mutation factories. Both were removed in 0.12.0. 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 then tails unread notification count changes. 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'

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}/site/devices`, {
        headers: { Authorization: `Bearer ${authStore.getState().token}` },
      })
      return res.json()
    },
  })
  devicesStore.getState().setSelectedDevices(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://gateway.example.com node script.ts

For the full list of query helpers, see the UI reference.

Next steps

  • UI reference: full store list, query helpers, and the createMdkQueryClient resolution 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.

On this page