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 { 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:

  • fetcher swaps the transport: pass a Fetcher that talks to a custom auth scheme, a different protocol, or serves fixtures from memory for a server-less demo.
  • endpoints remaps the :name path 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-backend example takes this further: it skips the query layer entirely and drives the same devkit components from plain TanStack useQuery against a foreign API shape, useful when a backend doesn't fit the fetcher/endpoints seam 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.ts

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