From 7356129b7273d96ce8187916081eb27bd850a2fa Mon Sep 17 00:00:00 2001 From: jkdevito Date: Mon, 17 Aug 2026 10:36:18 -0500 Subject: [PATCH 1/4] docs(#97): explain the Wrapper/Render (render props) pattern - Finish the render-props-pattern-explanation.md doc Copilot started: add the full DisplayControls render-half walkthrough, correct the claim that useGetAllDeviceStateFromRoomConfiguration shares the Redux syncState guard (it uses a component-local useRef instead), note the guard is keyed by Wrapper name (not device key), and flag the setSyncStateRequested() placement inconsistency between the two walkthrough examples. - Cross-link from device-state-feedback-app-dev.md back to this doc. Co-Authored-By: Claude Sonnet 5 --- docs/device-state-feedback-app-dev.md | 1 + docs/render-props-pattern-explanation.md | 273 +++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 docs/render-props-pattern-explanation.md 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..740cac3 --- /dev/null +++ b/docs/render-props-pattern-explanation.md @@ -0,0 +1,273 @@ +# 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 `Audio`/`AudioWrapper` and `DisplayControls`/`DisplayControlsWrapper` from `mobile-control-cisco-navigator-momentum-ui` (the same shape [Issue #97](https://github.com/PepperDash/mobile-control-react-app-core/issues/97) asks for from the KPMG app; this repo has the Cisco Navigator app checked out, and it uses the identical pattern with components of the same names). + +--- + +## 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` + +From `mobile-control-cisco-navigator-momentum-ui`: + +```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" + ? + :