@a4smanjorg5/react-serial
v0.4.0
Published
A tiny React hook for talking to serial devices from the browser via the Web Serial API.
Maintainers
Readme
@a4smanjorg5/react-serial
A tiny React hook for talking to serial devices from the browser via the Web Serial API.
useSerial handles port selection, opening the connection, continuously reading the incoming stream into React state, writing data back, and cleanly tearing everything down on unmount.
Requirements
- React
^18 || ^19(peer dependency) - A browser that supports the Web Serial API — Chrome / Edge (desktop). The hook will
alertand bail out wherenavigator.serialis unavailable. - The Web Serial API only works in a secure context (HTTPS or
localhost) and must be triggered by a user gesture (e.g. a button click).
Installation
npm install @a4smanjorg5/react-serial
# or
pnpm add @a4smanjorg5/react-serial
# or
yarn add @a4smanjorg5/react-serialUsage
import { useSerial } from '@a4smanjorg5/react-serial'
export function SerialMonitor() {
const { isConnected, receivedData, connectSerial, disconnectSerial, sendData } =
useSerial()
return (
<div>
{isConnected ? (
<button onClick={disconnectSerial}>Disconnect</button>
) : (
// Must be called from a user gesture
<button onClick={() => connectSerial({ baudRate: 9600 })}>Connect</button>
)}
<pre>{receivedData}</pre>
<button
disabled={!isConnected}
onClick={() => sendData(new TextEncoder().encode('ping\n'))}
>
Send "ping"
</button>
</div>
)
}By default incoming bytes are decoded as UTF-8 text, so receivedData is a string.
API
const {
info,
isConnected,
receivedData,
ports,
connectSerial,
disconnectSerial,
sendData,
} = useSerial<T = string>(onData?, options?)Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| onData | (data?: Uint8Array, prev?: Uint8Array \| T) => T | Reducer called for every chunk read from the port. Receives the raw bytes and the previous accumulated value, and returns the new value stored in receivedData. Defaults to UTF-8 decoding each chunk to a string. |
| options | SerialPortRequestOptions | Passed to navigator.serial.requestPort(), e.g. { filters: [{ usbVendorId: 0x2341 }] }, to limit the devices shown in the picker. |
Return value
| Field | Type | Description |
| --- | --- | --- |
| info | Partial<SerialPortInfo> \| undefined | USB vendor/product IDs (usbVendorId, usbProductId) of the connected port, or undefined when disconnected. Memoized, so it is safe to use in dependency arrays. |
| isConnected | boolean | Whether a port is currently open. Becomes false automatically if the device is unplugged. |
| receivedData | T \| undefined | Latest value produced by onData (a string by default). |
| ports | SerialPort[] | Ports the user has already granted access to. Kept in sync with the browser's connect / disconnect events. |
| connectSerial | (options?: SerialOptions, grantedPort?: SerialPort) => void | Opens a port, defaulting to { baudRate: 9600 }. Without grantedPort it prompts the user to pick one (must be invoked from a user gesture); pass an entry of ports to reconnect without a prompt. If a port is already open it is closed first, so calling it again switches device or applies new options without isConnected / info ever going empty. A different port is opened before the current one is released, so the current connection is kept if the picker is dismissed or the new port fails to open. Reopening the same port has to close it first, and isConnected becomes false only if that reopen fails. Ignored while a connect/disconnect is in progress. |
| disconnectSerial | () => void | Cancels the reader, discards writes that have not finished sending yet and closes the port cleanly. |
| sendData | (message: Uint8Array) => void | Writes bytes to the device. Writes are queued and sent in order. No-op when the port is not writable. |
connectSerial, disconnectSerial and sendData keep the same identity between renders. The port is closed automatically when the component unmounts, and the hook is safe to render on the server (it does nothing where navigator is unavailable).
SerialOptions
Passed to port.open():
| Field | Type | Default |
| --- | --- | --- |
| baudRate | number | 9600 |
| dataBits | number | — |
| stopBits | number | — |
| parity | 'none' \| 'even' \| 'odd' \| 'mark' \| 'space' | — |
| bufferSize | number | — |
| flowControl | 'none' \| 'hardware' | — |
Custom data handling
Pass an onData reducer to accumulate or transform incoming bytes. For example, to keep a running log of all decoded text:
const decoder = new TextDecoder()
const { receivedData } = useSerial<string>((data, prev = '') =>
prev + decoder.decode(data),
)Because the return type is generic, you can also build up structured state (e.g. parsing newline-delimited frames) by typing the hook accordingly: useSerial<string[]>(...).
Multiple devices
SerialProvider manages several devices at once, each identified by a string key. It renders no DOM of its own: every entry of devices gets its own useSerial connection, and useSerialDevice(key) reads it from anywhere below the provider.
import { SerialProvider, useSerialDevice } from '@a4smanjorg5/react-serial'
const parseWeight = (data?: Uint8Array, prev = 0) => prev + (data?.length ?? 0)
export function App() {
return (
<SerialProvider
devices={{
printer: {},
scale: {
onData: parseWeight,
options: { baudRate: 115200, parity: 'even' },
requestOptions: { filters: [{ usbVendorId: 0x2341 }] },
onConnectionChange: ({ key, type, info }) =>
console.log(key, type, info?.usbVendorId),
},
}}
>
<Scale />
</SerialProvider>
)
}
function Scale() {
const { isConnected, receivedData, connectSerial, disconnectSerial } =
useSerialDevice<number>('scale')
return isConnected ? (
<button onClick={disconnectSerial}>Disconnect ({receivedData})</button>
) : (
// Uses the `options` of this device. Must be called from a user gesture
<button onClick={() => connectSerial()}>Connect</button>
)
}SerialProvider
| Prop | Type | Description |
| --- | --- | --- |
| devices | Record<string, SerialDeviceConfig> | The devices to manage, keyed by their identifier. Use {} for a device with no configuration. |
| children | ReactNode | Anything that calls useSerialDevice. |
Each device accepts:
| Field | Type | Description |
| --- | --- | --- |
| onData | (data?: Uint8Array, prev?: unknown) => unknown | Same reducer as the first argument of useSerial. Defaults to UTF-8 decoding. |
| options | SerialOptions | Options this device is opened with by connectSerial(). Falls back to { baudRate: 9600 }. |
| requestOptions | SerialPortRequestOptions | Same as the second argument of useSerial, e.g. to filter the picker by vendor. |
| onConnectionChange | (event: SerialConnectionEvent) => void | Called when the device connects or disconnects. |
onConnectionChange receives { key, type: 'connect' | 'disconnect', info }. A disconnect is reported however it came about: disconnectSerial(), the device being unplugged, the stream ending by itself, the device being removed from devices, or the provider unmounting. In that case info is that of the port that was just released. It is not called on mount, nor when connectSerial switches to another port while staying connected. It always runs the latest callback, so it does not need to be memoized.
useSerialDevice
const { info, isConnected, receivedData, ports, connectSerial, disconnectSerial, sendData } =
useSerialDevice<T = string>(key)It returns the same shape as useSerial, bound to key, with one difference: connectSerial is (grantedPort?: SerialPort) => void and takes no SerialOptions, since those are set by options in the device config. Without grantedPort it prompts the user to pick a port (must be invoked from a user gesture); pass an entry of ports to reconnect without a prompt. connectSerial, disconnectSerial and sendData keep the same identity. A key that is not (yet) in devices gives an idle device (isConnected: false), and calling connectSerial or sendData on it logs an error and does nothing. It throws when used outside a SerialProvider.
Notes:
- Removing a device from
devicescloses its port. - A device added to
devicescan be connected once the provider has rendered it, not from the same handler that added it. - A port can only be open once, so two keys cannot share it.
devicesmay hold functions, so in Next.js renderSerialProviderfrom a client component.
Development
This package builds with tsup and lints/formats with Biome.
npm run build # one-off build (ESM + CJS + type declarations) into dist/
npm run start # build in watch mode
npm run lint # checks code quality