diff --git a/docs/device-state-feedback-app-dev.md b/docs/device-state-feedback-app-dev.md index 6edd7c4..c1dd976 100644 --- a/docs/device-state-feedback-app-dev.md +++ b/docs/device-state-feedback-app-dev.md @@ -124,6 +124,7 @@ function RoomPanel({ roomKey }: { roomKey: string }) { - Sends `fullStatus` requests once per mount (guarded by a `useRef`). - Pass `requestStatus={false}` to suppress the requests when you only need to observe state. +- Need "once per session" instead of once per mount (e.g. a screen behind app routing that unmounts/remounts)? See [render-props-pattern-explanation.md](./render-props-pattern-explanation.md) for the Wrapper/Render pattern built on `useStateIsSynced` and the Redux `syncState` guard. --- diff --git a/docs/render-props-pattern-explanation.md b/docs/render-props-pattern-explanation.md new file mode 100644 index 0000000..f828582 --- /dev/null +++ b/docs/render-props-pattern-explanation.md @@ -0,0 +1,276 @@ +# EXPLANATION: The Wrapper / Render Component Pattern ("Render Props") + +**Relates to:** [Issue #97](https://github.com/PepperDash/mobile-control-react-app-core/issues/97) · [Document Mobile Control data flow #89](https://github.com/PepperDash/mobile-control-react-app-core/issues/89) + +> **A note on naming:** Consuming apps refer to this as the "render props" pattern, but it is not the classic React render-prop API (a component that accepts a `render`/`children` function). It is a **Wrapper / Render component split** — one component owns data-sync concerns, and a second, separate component owns presentation. This document uses "render props" only because that is the established name for it across PepperDash Mobile Control apps; the mechanics described below are what actually happens. + +This pattern is not part of `mobile-control-react-app-core` itself — the library only provides the building blocks (selector hooks, `useWebsocketContext`, `useStateIsSynced`). The pattern is a convention that consuming apps follow when building screens on top of those building blocks. The examples below are adapted from the `Audio`/`AudioWrapper` and `DisplayControls`/`DisplayControlsWrapper` components in the separate `mobile-control-cisco-navigator-momentum-ui` consuming app (the same shape [Issue #97](https://github.com/PepperDash/mobile-control-react-app-core/issues/97) asks for from the KPMG app). + +--- + +## The Problem It Solves + +Every screen in a Mobile Control app needs device state that starts out empty. State only appears in the Redux store after Essentials pushes it over the WebSocket, and Essentials only pushes current state proactively for a device once the app asks for it (via a `fullStatus` request — see [action-paths-app-dev.md](./action-paths-app-dev.md)). + +A naive component would request status inside its own render logic, which creates two problems: + +- **Re-request storms.** Components re-render often (state changes, route changes, parent re-renders). If the status request lived in the same component that renders controls, it would fire repeatedly instead of once. +- **Mixed responsibilities.** Figuring out *which* device keys are relevant to a screen (by reading room configuration) is a different concern than *rendering* those devices once their state exists. + +The Wrapper / Render split exists to keep those two concerns apart. + +--- + +## The Two Halves + +### The Wrapper Component + +Responsible for: +1. Reading room configuration (`useRoomConfiguration`) and/or interface support (`useDeviceInterfaceSupport`) to determine **which device keys are relevant** to this screen. +2. Requesting full status for those device keys, **exactly once per session**, using the sync-state guard described below. +3. Rendering the Render component (and nothing else — no presentation logic). + +### The Render Component + +Responsible for: +1. Reading already-populated state back out of the Redux store via selector hooks (`useGetAllDevices`, `useGetDevice`, room selector hooks, etc.). +2. Rendering UI from that state. +3. Nothing about *how* or *when* that state was requested — it assumes the Wrapper has already taken care of it. + +This is the same idea as a container/presentational split, but the "container" half has one narrow job: make sure state exists, once. + +--- + +## Walkthrough: `AudioWrapper` / `Audio` + +_Example adapted from the separate [`mobile-control-cisco-navigator-momentum-ui`](https://github.com/PepperDash/mobile-control-cisco-navigator-momentum-ui) consuming app:_ + +```tsx +// AudioWrapper.tsx — the Wrapper +export const AudioWrapper = ({ className, variant }: AudioWrapperProps) => { + const { sendMessage } = useWebsocketContext(); + const roomKey = useRoomKey(); + const config = useRoomConfiguration(roomKey); + + const [setSyncStateRequested, , syncStateRequested] = + useStateIsSynced("AudioWrapper"); + + useEffect(() => { + if (!config || syncStateRequested) return; + + const deviceKeysSet: Set = new Set(); + const levelControls = config.audioControlPointList?.levelControls; + + if (levelControls && config.audioControlPointList) { + Object.values(levelControls).forEach((lcl) => { + deviceKeysSet.add( + lcl.itemKey ? `${lcl.parentDeviceKey}--${lcl.itemKey}` : lcl.parentDeviceKey + ); + }); + } + + deviceKeysSet.forEach((dk) => { + sendMessage(`/device/${dk}/fullStatus`, { deviceKey: dk }); + }); + + setSyncStateRequested(); + }, [config]); + + return variant === "dangerFeedback" + ? + :