Skip to content
All posts

22 October 20269 min read

IoT Device Control UI Design: State, Latency and Safety

A button in an IoT app changes the physical world through a device that may be asleep, out of range or already switched at the wall. How I handle desired and reported state, pending and confirmed updates, offline devices and safety, with a React toggle.

IoT Device Control UI Design: State, Latency and Safety

A button in a normal web app changes a database row a few milliseconds away. A button in an IoT app changes the physical world, via a device that may be asleep, out of range or already switched by someone at the wall. This guide covers the principles of IoT device control UI design that deal with that reality: state vs commands, optimistic vs confirmed updates, latency and offline devices, safety, accessibility and real-time transports. It ends with a React toggle that has proper pending, confirmed and error states. Several of these rules came up while I was building WireAmbi, an Android app that controls the LED strip in my car: its controller never reports its state back, so the app has to remember what it set.

Why IoT device control UI design is different

Device UIs are harder than CRUD screens for a few reasons:

  1. Latency is variable and unbounded. A command might be applied in 100 ms, in five seconds, or never.

  2. You're not the only source of truth. Someone can flip the physical switch, an automation can run, or the device can reboot into a default state.

  3. Mistakes have physical consequences. Showing "Door closed" when it's open is a safety problem, not just a cosmetic bug.

Separate desired state from reported state

The most useful mental model is to keep two values per device property:

  • Desired state: what the user (or an automation) asked for.

  • Reported state: what the device last said is actually true.

Cloud IoT platforms often formalise this as a "device shadow" or "digital twin", and your own backend should model it the same way. The UI's job is then clear: show reported state as the truth, and indicate a pending desired state when the two differ.

Related to this: send idempotent state commands, not toggles. { "set": { "on": true } } can safely be retried or delivered twice. A bare toggle command delivered twice leaves the lamp where it started. Give every command an ID, too, so the device can say "I applied command abc123" and the UI knows exactly which request was confirmed.

Optimistic UI vs confirmed state for IoT controls

Optimistic UI updates the screen immediately and reconciles later. In a to-do app, that's a great default. For devices, decide per control:

Control

Recommended approach

Why

Lamp, RGB colour, speaker volume

Optimistic, with a subtle pending indicator

Low risk, and instant feedback feels right

Thermostat setpoint

Optimistic setpoint, confirmed current temperature

Show both "target" and "actual"

Garage door, gate, lock

Confirmed only

The user must not believe the door is closed when it isn't

Heaters, pumps, relays on mains loads

Confirmed only, plus safeguards

Wrong state can damage equipment or cause harm

A good rule: if a wrong display could make someone act unsafely, never show the new state before the device confirms it. Show "Closing…" until the door's own sensor reports it's closed.

Designing for latency, timeouts and offline devices

A slow control is annoying, but a control that shows the wrong state is worse.

  • Show pending state immediately: a spinner, a pulsing outline or a "Turning on…" label within a frame of the tap.

  • Set a confirmation timeout, then move to an explicit error state with a retry action. Don't spin forever.

  • Make offline obvious: disable controls and show "last seen" time. With MQTT, a Last Will message can publish offline automatically when a device drops off.

  • Mark stale data: a reading from three hours ago shouldn't look like one from three seconds ago.

  • Reconcile on reconnect: fetch the current state before accepting new input.

Feedback: pending, confirmed and error states

Every control needs distinct visual and textual states: confirmed (the reported state), pending (what was requested), error (what went wrong in plain language, plus a retry) and offline/unknown, which is not the same as "off".

Don't rely on colour alone. Green versus grey is hard to tell apart for many colour-blind users and in bright sunlight, so pair colour with text or icons.

Safety for destructive and physical actions

Your UI is not a safety system. Real interlocks (limit switches, thermal cutoffs, obstruction sensors) belong in the device firmware and hardware. The UI can still prevent accidents:

  • Add friction proportional to risk: a confirmation step, slide-to-confirm or press-and-hold for gates and locks, never a single tap that fires in a pocket.

  • Name the target: "Open Garage door (North)?" beats "Are you sure?".

  • Show evidence: put the door sensor reading or camera view next to the control.

  • Guard against repeats: ignore taps while a command is pending, and rate-limit on the server.

  • Log who did what, so "who opened the gate at 3 a.m.?" has an answer.

Accessibility for smart home and IoT controls

  • Use a real <button> with role="switch" and aria-checked for on/off controls, so screen readers announce "Living room lamp, switch, on".

  • Announce state changes with an aria-live="polite" region, since the confirmation arrives asynchronously and isn't tied to a focus change.

  • Mark in-progress controls with aria-busy. Avoid disabled on pending controls: it drops the button from the focus order. aria-disabled plus a guarded click handler keeps focus stable.

  • Size touch targets generously. WCAG 2.2 requires at least 24 × 24 CSS pixels at level AA (SC 2.5.8) and 44 × 44 at AAA (SC 2.5.5). Aim for the larger size.

  • Respect prefers-reduced-motion for pulsing and spinning indicators.

Mobile-first layout for device dashboards

Most device control happens on a phone, one-handed, on a flaky connection. Put frequent controls within thumb reach, group devices by room rather than type, let users pin favourites, and test with network throttling in dev tools, which is where your pending states earn their keep.

Real-time transport: WebSockets vs MQTT over WebSocket vs SSE

You need to push reported state to the browser as it changes. The three common options:

WebSockets

MQTT over WebSocket

Server-Sent Events (SSE)

Direction

Bidirectional

Bidirectional (pub/sub)

Server → client only

Commands from UI

Same socket

Publish to a topic

Separate POST request

Reconnect

You implement it

Client libraries handle it

Built into EventSource

Last known state on connect

You implement it

Retained messages

You implement it

Offline detection

Heartbeats

Last Will and Testament

Heartbeats

Best when

You own a custom backend

Devices already speak MQTT

Mostly read-only dashboards

If your devices already speak MQTT, connecting the browser to the broker over WebSocket gives you a lot for free: retained state topics, a set topic for commands and an availability topic set by Last Will (with authentication and per-user ACLs, always). For a simple dashboard behind your own API, SSE plus POST has the fewest moving parts.

A React device toggle with pending, confirmed and error states

Here's a compact implementation that puts these IoT device control UI design principles into practice. First, the contract with whatever transport you use:

TypeScript
// device-types.ts
export type ReportedState = {
  on: boolean
  online: boolean
  updatedAt: number // ms since epoch, as reported by the device or broker
  ackFor?: string // ID of the command this report confirms, if any
}

export type SetCommand = { id: string; set: { on: boolean } }

export interface DeviceTransport {
  /** Resolves when the command was sent, NOT when the device applied it. */
  send(deviceId: string, cmd: SetCommand): Promise<void>
  /** Calls onState for every reported state. Returns an unsubscribe function. */
  subscribe(deviceId: string, onState: (s: ReportedState) => void): () => void
}

The hook tracks reported state and the lifecycle of the current command:

TypeScript
// useDeviceToggle.ts
import { useCallback, useEffect, useState } from 'react'
import type { DeviceTransport, ReportedState } from './device-types'

export type ToggleStatus =
  | { kind: 'idle' }
  | { kind: 'pending'; commandId: string; desired: boolean }
  | { kind: 'error'; desired: boolean; message: string }

const CONFIRM_TIMEOUT_MS = 5000

export function useDeviceToggle(deviceId: string, transport: DeviceTransport) {
  const [reported, setReported] = useState<ReportedState | null>(null)
  const [status, setStatus] = useState<ToggleStatus>({ kind: 'idle' })

  useEffect(() => {
    return transport.subscribe(deviceId, (s) => {
      setReported(s)
      setStatus((cur) =>
        cur.kind === 'pending' && (s.ackFor === cur.commandId || s.on === cur.desired)
          ? { kind: 'idle' }
          : cur
      )
    })
  }, [deviceId, transport])

  useEffect(() => {
    if (status.kind !== 'pending') return
    const timer = setTimeout(() => {
      setStatus((cur) =>
        cur.kind === 'pending' && cur.commandId === status.commandId
          ? { kind: 'error', desired: cur.desired, message: "The device didn't confirm the change." }
          : cur
      )
    }, CONFIRM_TIMEOUT_MS)
    return () => clearTimeout(timer)
  }, [status])

  const setOn = useCallback(
    async (desired: boolean) => {
      const commandId = crypto.randomUUID()
      setStatus({ kind: 'pending', commandId, desired })
      try {
        await transport.send(deviceId, { id: commandId, set: { on: desired } })
      } catch {
        setStatus((cur) =>
          cur.kind === 'pending' && cur.commandId === commandId
            ? { kind: 'error', desired, message: 'Could not reach the server.' }
            : cur
        )
      }
    },
    [deviceId, transport]
  )

  return { reported, status, setOn }
}

And the component. The optimistic prop lets you use the same toggle for a lamp (optimistic) and a relay on a heater (confirmed only):

TSX
// DeviceToggle.tsx
import { useDeviceToggle } from './useDeviceToggle'
import type { DeviceTransport } from './device-types'

type Props = {
  deviceId: string
  label: string
  transport: DeviceTransport
  optimistic?: boolean
}

export function DeviceToggle({ deviceId, label, transport, optimistic = true }: Props) {
  const { reported, status, setOn } = useDeviceToggle(deviceId, transport)

  const unknown = reported === null
  const offline = reported !== null && !reported.online
  const pending = status.kind === 'pending'
  const confirmedOn = reported?.on ?? false
  const shownOn = pending && optimistic ? status.desired : confirmedOn

  let message = ''
  if (unknown) message = 'Connecting…'
  else if (offline) message = `Offline, last seen ${new Date(reported.updatedAt).toLocaleTimeString()}`
  else if (pending) message = status.desired ? 'Turning on…' : 'Turning off…'
  else if (status.kind === 'error') message = status.message

  return (
    <div className="device-toggle">
      <button
        type="button"
        role="switch"
        aria-checked={shownOn}
        aria-busy={pending}
        aria-disabled={pending || undefined}
        aria-describedby={`${deviceId}-status`}
        disabled={unknown || offline}
        onClick={() => {
          if (!pending) setOn(!confirmedOn)
        }}
        className={`switch${shownOn ? ' is-on' : ''}${pending ? ' is-pending' : ''}`}
      >
        <span>{label}</span>
        <span aria-hidden="true">{shownOn ? 'On' : 'Off'}</span>
      </button>
      <p id={`${deviceId}-status`} role="status" aria-live="polite">
        {message}
      </p>
      {status.kind === 'error' && (
        <button type="button" onClick={() => setOn(status.desired)}>
          Retry
        </button>
      )}
    </div>
  )
}

Note that the click handler computes the new value from reported state, not the displayed one, and that confirmation accepts either an ackFor match or a report matching the desired value, which covers devices that don't echo command IDs.

To plug this into MQTT over WebSocket, subscribe listens on devices/<id>/state (a retained topic) and send publishes the command JSON to devices/<id>/set. For SSE, subscribe wraps an EventSource and send is a fetch POST.

If you need a control panel for your own device, see web apps, Android apps and electronics.

FAQ

Should IoT apps use optimistic UI?

For low-risk controls like lights, yes, with a visible pending state and a rollback on timeout. For locks, doors, heaters and anything safety-relevant, show only confirmed state.

How long should a device command timeout be?

Base it on your devices' measured round-trip time plus margin. Sleepy battery devices may need a "will apply on next wake-up" state instead.

Is MQTT over WebSocket safe to use from the browser?

It can be, if the broker requires authentication over TLS and uses per-user topic ACLs. Never expose an anonymous broker to the internet.

What's the difference between WebSockets and SSE for IoT dashboards?

WebSockets are bidirectional. SSE only streams server-to-client, but it reconnects automatically and works well with a separate POST endpoint for commands.

How do I show that a device is offline?

Have the device (or the broker's Last Will message) publish availability, show a clear offline badge with "last seen" time, and disable its controls.

Write