From 22a44d18b047131ec565050045a1ab719f19fcd4 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 11 May 2026 16:31:52 +0000 Subject: [PATCH] Add useNitroEventState / useMessageEventState hooks (proposal #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the building block for reducing the state-from-event boilerplate that pervades the codebase: // Before const [foo, setFoo] = useState(initial); useNitroEvent(SOME_EVENT, e => setFoo(e.payload)); // After const foo = useNitroEventState(SOME_EVENT, e => e.payload, initial); Implementation notes: - src/hooks/events/useNitroEventState.ts wraps useNitroEvent so the selector closure can use up-to-date surrounding values (captured in a ref refreshed in commit via useLayoutEffect) without forcing a re-subscription on every render. Listener is registered once and always reads the latest selector. - src/hooks/events/useMessageEventState.ts is the mirror for useMessageEvent (server message channel — request/response composers and push parsers). - Both pass the new react-hooks v7 rules cleanly (in particular the strict react-hooks/refs that forbids ref mutation during render). - Re-exported from src/hooks/events/index.ts so callers reach them via the existing `from '../../hooks'` import path. Pilot adoption (1 site) to demonstrate the pattern: - src/components/catalog/views/targeted-offer/OfferView.tsx: the offer state was a clean derive-from-event case (setOffer(parser.data) on TargetedOfferEvent, no other writes). Replaced with a single useMessageEventState call using the optional chain `evt.getParser()?.data ?? null` as selector. Removes the useState pair and the explicit subscription block. Honest scope note: A broader sweep is intentionally NOT done. Most existing event subscriptions in this codebase are multi-state updates, state machines, conditional filters ("skip if not my id"), or have side effects mixed in (notifications, redirects). Forcing those into useNitroEventState would lose information and risk regressions in behavior the lint won't catch. Adoption should happen organically when contributors see a clean derive-from-event case, not as a mechanical replace-all. https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q --- .../views/targeted-offer/OfferView.tsx | 17 +++----- src/hooks/events/index.ts | 2 + src/hooks/events/useMessageEventState.ts | 34 +++++++++++++++ src/hooks/events/useNitroEventState.ts | 42 +++++++++++++++++++ 4 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 src/hooks/events/useMessageEventState.ts create mode 100644 src/hooks/events/useNitroEventState.ts diff --git a/src/components/catalog/views/targeted-offer/OfferView.tsx b/src/components/catalog/views/targeted-offer/OfferView.tsx index eb8cead..4fa945b 100644 --- a/src/components/catalog/views/targeted-offer/OfferView.tsx +++ b/src/components/catalog/views/targeted-offer/OfferView.tsx @@ -1,24 +1,19 @@ import { GetTargetedOfferComposer, TargetedOfferData, TargetedOfferEvent } from '@nitrots/nitro-renderer'; import { useEffect, useState } from 'react'; import { SendMessageComposer } from '../../../../api'; -import { useMessageEvent } from '../../../../hooks'; +import { useMessageEventState } from '../../../../hooks'; import { OfferBubbleView } from './OfferBubbleView'; import { OfferWindowView } from './OfferWindowView'; export const OfferView = () => { - const [ offer, setOffer ] = useState(null); + const offer = useMessageEventState( + TargetedOfferEvent, + evt => evt.getParser()?.data ?? null, + null + ); const [ opened, setOpened ] = useState(false); - useMessageEvent(TargetedOfferEvent, evt => - { - let parser = evt.getParser(); - - if(!parser) return; - - setOffer(parser.data); - }); - useEffect(() => { SendMessageComposer(new GetTargetedOfferComposer()); diff --git a/src/hooks/events/index.ts b/src/hooks/events/index.ts index 6d9903b..6b5c11e 100644 --- a/src/hooks/events/index.ts +++ b/src/hooks/events/index.ts @@ -1,4 +1,6 @@ export * from './useEventDispatcher'; export * from './useMessageEvent'; +export * from './useMessageEventState'; export * from './useNitroEvent'; +export * from './useNitroEventState'; export * from './useUiEvent'; diff --git a/src/hooks/events/useMessageEventState.ts b/src/hooks/events/useMessageEventState.ts new file mode 100644 index 0000000..11fa864 --- /dev/null +++ b/src/hooks/events/useMessageEventState.ts @@ -0,0 +1,34 @@ +import { IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { useMessageEvent } from './useMessageEvent'; + +/** + * Subscribe to a server message event and expose the latest derived + * value as React state. Mirror of useNitroEventState for the Nitro + * communication channel (request/response composers, push parsers). + * + * const data = useMessageEventState(SomeParser, e => e.getParser().data, null); + */ +export const useMessageEventState = ( + eventType: typeof MessageEvent, + selector: (event: T) => S, + initial: S | (() => S) +): S => +{ + const [ value, setValue ] = useState(initial); + const selectorRef = useRef(selector); + + useLayoutEffect(() => + { + selectorRef.current = selector; + }); + + const handler = useCallback((event: T) => + { + setValue(selectorRef.current(event)); + }, []); + + useMessageEvent(eventType, handler); + + return value; +}; diff --git a/src/hooks/events/useNitroEventState.ts b/src/hooks/events/useNitroEventState.ts new file mode 100644 index 0000000..48fab86 --- /dev/null +++ b/src/hooks/events/useNitroEventState.ts @@ -0,0 +1,42 @@ +import { NitroEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { useNitroEvent } from './useNitroEvent'; + +/** + * Subscribe to a Nitro renderer event and expose the latest derived value + * as React state. Replaces the boilerplate pattern: + * + * const [foo, setFoo] = useState(initial); + * useNitroEvent(EVENT, e => setFoo(selector(e))); + * + * with: + * + * const foo = useNitroEventState(EVENT, selector, initial); + * + * The selector closure is captured in a ref refreshed in commit, so + * a new selector identity per render does not re-subscribe the listener. + */ +export const useNitroEventState = ( + type: string | string[], + selector: (event: T) => S, + initial: S | (() => S), + enabled: boolean = true +): S => +{ + const [ value, setValue ] = useState(initial); + const selectorRef = useRef(selector); + + useLayoutEffect(() => + { + selectorRef.current = selector; + }); + + const handler = useCallback((event: T) => + { + setValue(selectorRef.current(event)); + }, []); + + useNitroEvent(type, handler, enabled); + + return value; +};