Hooks

What are hooks?

Hooks are the recommended way for the developer to get state into their components.

By convention they will always start with a use prefix to denote their hook status.

Take the following example, which displays the brightness as a percentage, and also colors the background based on the current percentage.

import { useHardwareState } from '@electricui/components-core'
  1. const ComponentThatTakesState = () => {
  2. const brightnessPercentage = useHardwareState('brightness')
  3. let color = 'white'
  4. if (brightnessPercentage > 50) {
  5. color = 'red'
  6. } else if (brightnessPercentage < 20) {
  7. color = 'blue'
  8. }
  9. return (
  10. <div style={{ background: color, padding: 20 }}>
  11. LED Brightness: {brightnessPercentage}%
  12. </div>
  13. )
  14. }

While it may look like the useHardwareState function call merely grabs the current hardware state, a bit more is happening under the hood.

The hook understands the device context it resides in, automatically fetching the state of the correct device.

Each call to a hook is registered by the renderer in order. In the event the brightness changes, this component will automatically re-render. If the state doesn't change, the component won't re-render.

There are several rules and a few strong recommendations regarding the usage of hooks. For a technical explanation of why, skip to the end.

Only call hooks at the top level.

Don't call hooks inside loops, conditionals or nested functions. Call the same hooks in the same order every render.

Hook calls are recorded and maintained in order, as a result, their order must be maintained.

This is not allowed:

  1. const ComponentThatTakesState = () => {
  2. const ledError = useHardwareState('led_error_code')
  3. if (ledError) {
  4. return <div>Failed to grab LED color.</div>
  5. }
  6. const brightnessPercentage = useHardwareState('brightness')
  7. let color
  8. if (brightnessPercentage > 0) {
  9. color = useHardwareState('color')
  10. }
  11. return (
  12. <div>
  13. LED Brightness: {brightnessPercentage}%
  14. {brightnessPercentage > 0 ? <> LED Color: {color}</> : null}
  15. </div>
  16. )
  17. }

If the led_error_code changes, the amount of hooks rendered per render will change.

If the brightness switches between 0 and a value above 0, again the amount of hooks rendered per render will change.

Instead, call the hooks at the top level, calling the same amount each render.

  1. const ComponentThatTakesState = () => {
  2. const ledError = useHardwareState('led_error_code')
  3. const brightnessPercentage = useHardwareState('brightness')
  4. const color = useHardwareState('color')
  5. if (ledError) {
  6. return <div>Failed to grab LED color.</div>
  7. }
  8. return (
  9. <div>
  10. LED Brightness: {brightnessPercentage}%
  11. {brightnessPercentage > 0 ? <> LED Color: {color}</> : null}
  12. </div>
  13. )
  14. }

Only call hooks within function based components

Hooks rely on information stored within the React render tree, calling them outside of this tree will result in an error.

This is not allowed:

  1. const brightnessDataSource = new MessageDataSource('brightness')
  2. const averageBrightness = new DataTransformer(
  3. brightnessDataSource,
  4. () => ({
  5. queue: new FixedQueue<Event<number>>(100), // store up to the last 100 brightness events
  6. }),
  7. (event, dataSource, state, emit) => {
  8. // Add the brightness event to the queue
  9. state.queue.push(event)
  10. // NOT ALLOWED, this isn't a component in the React render tree
  11. const averagingTimeWindow = useHardwareState('b_time_window') ?? 2000
  12. let total = 0
  13. let counter = 0
  14. const queueArray = state.queue.copy()
  15. const now = timing.now()
  16. for (let index = 0; index < queueArray.length; index++) {
  17. // Only use values from the past `averagingTimeWindow` of time
  18. if (event.time < now - averagingTimeWindow) {
  19. continue
  20. }
  21. total += queueArray[index].data // add the brightness value
  22. counter += 1
  23. }
  24. if (counter === 0) {
  25. return
  26. }
  27. const average = total / counter
  28. // Emit our averaged event
  29. emit(new Event(event.time, average))
  30. },
  31. )

In most cases, there will be an API dedicated to providing the state you need outside of the render tree.

For example, the above example is idiomatically demonstrated by moving the averagingTimeWindow state into the DataTransformer itself, then mutating it from the React render tree.

  1. const brightnessDataSource = new MessageDataSource('brightness')
  2. const averageBrightness = new DataTransformer(
  3. brightnessDataSource,
  4. () => ({
  5. queue: new FixedQueue<Event<number>>(100), // store up to the last 100 brightness events
  6. averagingTimeWindow: 0, // Initially the averaging time window is 0ms
  7. }),
  8. (event, dataSource, state, emit) => {
  9. // Add the brightness event to the queue
  10. state.queue.push(event)
  11. let total = 0
  12. let counter = 0
  13. const queueArray = state.queue.copy()
  14. const now = timing.now()
  15. for (let index = 0; index < queueArray.length; index++) {
  16. // Only use values from the past `averagingTimeWindow` of time
  17. if (event.time < now - state.averagingTimeWindow) {
  18. continue
  19. }
  20. total += queueArray[index].data // add the brightness value
  21. counter += 1
  22. }
  23. if (counter === 0) {
  24. return
  25. }
  26. const average = total / counter
  27. // Emit our averaged event
  28. emit(new Event(event.time, average))
  29. },
  30. )

Then within a React component:

  1. function AverageBrightnessChart() {
  2. // The useDataSubscription hook returns functions that can be called without the hook rules.
  3. const { getDataSourceFromReference } = useDataSubscription()
  4. // Subscribe to the hardware state, if `b_time_window` ever changes while this component is mounted,
  5. // update the data source of _this device_.
  6. useHardwareStateSubscription('b_time_window', (value: number) => {
  7. // Get _this device's_ data source. We can strictly type it to get autocomplete on
  8. // the state with the 'as' type annotation.
  9. const ourDataSource = getDataSourceFromReference(
  10. averageBrightness,
  11. ) as typeof averageBrightness
  12. // Set the mutable state of our version of the data transformer to the value from hardware.
  13. ourDataSource.state.averagingTimeWindow = value
  14. })
  15. // The rest is a regular chart
  16. return (
  17. <>
  18. <div style={{ textAlign: 'center', marginBottom: '1em' }}>
  19. <b>Average brightness</b>
  20. </div>
  21. <ChartContainer height={400}>
  22. <LineChart
  23. dataSource={averageBrightness}
  24. maxItems={8000}
  25. lineWidth={2}
  26. />
  27. <RealTimeDomain window={30_000} />
  28. <TimeAxis />
  29. <VerticalAxis />
  30. </ChartContainer>
  31. </>
  32. )
  33. }

Here a hardware state subscription is setup. When the b_time_window messageID is updated, the averageBrightness Data Transformer is grabbed. The useDataSubscription hook makes sure the current device context is included, so the provided ourDataSource return is the correct version of the data source for the device on screen.

The as typeof averageBrightness type annotation gives us autocomplete on the state property of the Data Transformer.

The Data Transformers internal state can then be mutated, passing the value of the b_time_window messageID into the averagingTimeWindow state key.

Grab state as late as possible, as deep as possible

While not a rule, it is heavily encouraged to use hooks as deep in the component tree as is reasonable.

It is tempting to have several useHardwareState hooks on the main page of your user interface, and while this will function, each state update will cause a re-render for the entire page component, redoing unnecessary work.

By breaking the user interface up into smaller components that update independently, updates can be smaller and therefore faster.

Don't do this if at all possible:

  1. const Page = () => {
  2. const state1 = useHardwareState('state1')
  3. const state2 = useHardwareState('state2')
  4. // ...
  5. const state42 = useHardwareState('state42')
  6. return <>{/* Very large page with lots of things */}</>
  7. }

Instead break the page up into sub components that grab only the state they need:

  1. const SubComponent1 = () => {
  2. const stateRequired = useHardwareState('state1')
  3. return <>stateRequired is {stateRequired}</>
  4. }
  5. const SubComponent2 = () => {
  6. const stateRequired = useHardwareState('state2')
  7. return <>stateRequired 2 is {stateRequired}</>
  8. }
  9. const Page = () => {
  10. return (
  11. <>
  12. <SubComponent1 />
  13. <SubComponent2 />
  14. </>
  15. )
  16. }

Using hooks

Simply import them, then use them in your components.

import { useHardwareState } from '@electricui/components-core'
  1. const ComponentThatTakesState = () => {
  2. const brightnessPercentage = useHardwareState('brightness')
  3. return <div>LED Brightness: {brightness}%</div>
  4. }

Electric UI Hook API

All hooks can be imported from @electricui/components-core.

import { ... } from '@electricui/components-core'

Reading and Writing State

useHardwareState(accessor: Accessor<T>, options: UseHardwareStateOptions) => null | T
useInterfaceState(accessor: InterfaceAccessor<T>, options: UseInterfaceStateOptions) => null | T
useWriteState() => (writer: FunctionalWriter | StateTree, ack: boolean, cancellationToken: CancellationToken) => Promise<any>
useQuery() => (messageID: string, cancellationToken: CancellationToken) => Promise<any>
useCommitState() => (writer: FunctionalWriter | StateTree) => MessageID[]
usePushMessageIDs() => (messageIDs: string[], ack: boolean, cancellationToken: CancellationToken) => Promise<any>
useCommitStateStaged() => [GenerateStaging, CommitStaging]
useSendMessage() => void
useSendCallback() => void
useIntervalRequester(messageIDs: Array<string>, interval: number, timeout: undefined | number, acceptableConsecutiveFailures: number) => void

Managing Devices

Dealing with many devices:

No hook information available for useDeviceManager
useDeviceIDList() => string[]
usePollForDevices(pollingTime: number) =>

Dealing within a device context:

useDeviceConnect(deviceID: undefined | string) => void
useDeviceDisconnect(deviceID: undefined | string) => void
useDeviceConnectionHashes(deviceID: undefined | string) => string[]
useDeviceConnectionRequested(deviceID: undefined | string) => boolean
useDeviceConnectionState(deviceID: undefined | string) => CONNECTION_STATE
useDeviceHandshakeProgress(deviceHandshakeID: string, deviceID: undefined | string) => DeviceHandshakeProgress | null
useDeviceHandshakeProgressIDs(deviceID: undefined | string) => string[]
useDeviceHandshakeState(deviceID: undefined | string) => HANDSHAKE_STATE
useDeviceHasAcceptableConnection(deviceID: undefined | string) => boolean
useDeviceID() => string | null
useDeviceIDByMetadata(metadata: DeviceMetadata) => null | string
useDeviceMetadataKey(key: string, deviceID: undefined | string) => any

Connections

useConnectionAcceptability(connectionHash: string) => boolean
useConnectionMetadata(connectionHash: string) => { [key: string]: any }
useConnectionMetadataKey(connectionHash: string, key: string) => null | T
useConnectionState(connectionHash: string) => CONNECTION_STATE

Save Containers

Documented in the save container documentation.

useSaveContainer(ack: boolean) =>

Behind the scenes

All of Electric UIs hooks are composed of primatives that either hold state (the React useState hook), or cause effects (the React useEffect) hook. (There are others, but they are an implementation detail.)

A brief foray into React's API surface, the useState hook takes a default value and returns a tuple of the current state, and a setter function.

  1. const [state, setState] = useState(false)
  2. setState(true)
  3. // state = true
  4. setState(false)
  5. // state = false

Components can make multiple calls to each hook, so how does React know which call is which in our code?

  1. function Component() {
  2. const [state1, setState1] = useState(false) // call A
  3. const [state2, setState2] = useState(false) // call B
  4. return (
  5. <div>
  6. State 1: {state1}
  7. State 2: {state2}
  8. </div>
  9. )
  10. }

The defaults can be the same and the useState function can't know that the tuple is destructured into state1 and state2 variables.

The answer relies on ordering.

When the component is rendered, a counter is set to 0, each hook call increments that counter and whatever state is necessary is allocated into a 'cell' that persists with the instance of this component within the render tree.

As long as the component calls the same number of hook functions in the same order each time, the cells will match up with what's intended.

This breaks down if there is a branch, hence the first rule of hooks, to only call them at the 'top level'.

  1. function Component() {
  2. const [state1, setState1] = useState(false) // call A
  3. if (state1 === true) {
  4. const [state2, setState2] = useState('hello') // call B
  5. return (
  6. <div>
  7. State 1: {state1}
  8. State 2: {state2}
  9. </div>
  10. )
  11. }
  12. const [state3, setState3] = useState(123) // call C
  13. return <div>State 2: {state2}</div>
  14. }

When the component renders the first time, call B will be skipped, the cell table will look like:

  1. call 1: false
  2. call 2: 123

If state1 is set to true, the branch will be evaluated, and call B will be made in the second slot.

  1. call 1: false
  2. call 2: 123
  3. call 3: ???

Call B will be provided with 'its' state, which according to the cells is 123, which is incorrect. When Call C occurs, React knows something went wrong, since a different number of hooks ran this time.