diff --git a/apps/registry/app/report/report-bug-form.tsx b/apps/registry/app/report/report-bug-form.tsx index cefa2d02..50c42237 100644 --- a/apps/registry/app/report/report-bug-form.tsx +++ b/apps/registry/app/report/report-bug-form.tsx @@ -121,12 +121,16 @@ function TextField({ ); } +function useInitialState(initialValue: T) { + return useState(initialValue); +} + export function ReportBugForm({ initialComponent = "", }: { initialComponent?: string; }) { - const [component, setComponent] = useState(initialComponent); + const [component, setComponent] = useInitialState(initialComponent); const [summary, setSummary] = useState(""); const [repro, setRepro] = useState(""); const [expected, setExpected] = useState(""); diff --git a/apps/registry/components/storybook-embed/storybook-embed.tsx b/apps/registry/components/storybook-embed/storybook-embed.tsx index 31e02861..ab800090 100644 --- a/apps/registry/components/storybook-embed/storybook-embed.tsx +++ b/apps/registry/components/storybook-embed/storybook-embed.tsx @@ -109,10 +109,6 @@ function StorybookIframe({ }): React.ReactElement { const [isLoaded, setIsLoaded] = React.useState(false); - React.useEffect(() => { - setIsLoaded(false); - }, [iframeSource]); - return (
{isLoaded ? null : ( @@ -201,6 +197,7 @@ export function StorybookEmbed({ /> {iframeSource ? ( ({ ...DEFAULT_LABELS, ...labels }), [labels], ); - const [uncontrolled, setUncontrolled] = useState(defaultOpen); - const isControlled = controlledOpen !== undefined; - const openState = isControlled ? controlledOpen : uncontrolled; + const [openState, setOpenState] = useControllableState({ + defaultValue: defaultOpen, + onChange: onOpenChange, + value: controlledOpen, + }); const setOpen = useCallback( (next: boolean) => { - if (!isControlled) setUncontrolled(next); - onOpenChange?.(next); + setOpenState(next); }, - [isControlled, onOpenChange], + [setOpenState], ); const open = useCallback(() => { diff --git a/apps/registry/registry/default/animated-text/animated-text.tsx b/apps/registry/registry/default/animated-text/animated-text.tsx index 10a4938e..1a92af01 100644 --- a/apps/registry/registry/default/animated-text/animated-text.tsx +++ b/apps/registry/registry/default/animated-text/animated-text.tsx @@ -166,7 +166,6 @@ function useRevealProgress(active: boolean, length: number, stagger: number) { React.useEffect(() => { if (!active) { - setProgress(length); return; } @@ -191,7 +190,7 @@ function useRevealProgress(active: boolean, length: number, stagger: number) { }; }, [active, length, stagger]); - return progress; + return active ? progress : length; } function useMatrixFrame({ diff --git a/apps/registry/registry/default/faq/faq.tsx b/apps/registry/registry/default/faq/faq.tsx index 4d4ecd19..aff20edc 100644 --- a/apps/registry/registry/default/faq/faq.tsx +++ b/apps/registry/registry/default/faq/faq.tsx @@ -1,10 +1,9 @@ "use client"; -import { useState } from "react"; - import { ChevronDown, HelpCircle } from "lucide-react"; import type { ReactNode } from "react"; +import { useUncontrolledState } from "@vllnt/ui"; import { cn } from "@vllnt/ui"; export type FAQItemProps = { @@ -14,7 +13,7 @@ export type FAQItemProps = { }; function FAQItem({ children, defaultOpen = false, question }: FAQItemProps) { - const [isOpen, setIsOpen] = useState(defaultOpen); + const [isOpen, setIsOpen] = useUncontrolledState(defaultOpen); return (
diff --git a/apps/registry/registry/default/flashcard/flashcard.tsx b/apps/registry/registry/default/flashcard/flashcard.tsx index c9667126..156f5a7b 100644 --- a/apps/registry/registry/default/flashcard/flashcard.tsx +++ b/apps/registry/registry/default/flashcard/flashcard.tsx @@ -1,10 +1,9 @@ "use client"; -import { useState } from "react"; - import { RefreshCcw } from "lucide-react"; import type { ReactNode } from "react"; +import { useUncontrolledState } from "@vllnt/ui"; import { cn } from "@vllnt/ui"; import { Badge } from "@vllnt/ui"; import { Button } from "@vllnt/ui"; @@ -31,7 +30,7 @@ export function Flashcard({ question, title = "Flashcard", }: FlashcardProps): ReactNode { - const [flipped, setFlipped] = useState(defaultFlipped); + const [flipped, setFlipped] = useUncontrolledState(defaultFlipped); const toggleFlipped = (): void => { const nextValue = !flipped; diff --git a/apps/registry/registry/default/keyboard-shortcuts-help/keyboard-shortcuts-help.tsx b/apps/registry/registry/default/keyboard-shortcuts-help/keyboard-shortcuts-help.tsx index 4f74ea1d..e71f960e 100644 --- a/apps/registry/registry/default/keyboard-shortcuts-help/keyboard-shortcuts-help.tsx +++ b/apps/registry/registry/default/keyboard-shortcuts-help/keyboard-shortcuts-help.tsx @@ -4,6 +4,10 @@ import { memo, useEffect, useRef } from "react"; import type { ReactNode } from "react"; +import { + useEventCallback, + useWindowEventListener, +} from "@vllnt/ui"; import { cn } from "@vllnt/ui"; export type KeyboardShortcut = { @@ -32,25 +36,21 @@ function KeyboardShortcutsHelpImpl({ title = "Keyboard Shortcuts", }: KeyboardShortcutsHelpProps): React.ReactNode { const closeButtonRef = useRef(null); + const handleEscapeKey = useEventCallback((event: KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + } + }); // Focus trap and close on Escape useEffect(() => { if (!isOpen) return; closeButtonRef.current?.focus(); + }, [isOpen]); - const handleKeyDown = (event: KeyboardEvent): void => { - if (event.key === "Escape") { - event.preventDefault(); - onClose(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [isOpen, onClose]); + useWindowEventListener("keydown", handleEscapeKey, { enabled: isOpen }); // Prevent body scroll when open useEffect(() => { diff --git a/apps/registry/registry/default/map-timeline/map-timeline.tsx b/apps/registry/registry/default/map-timeline/map-timeline.tsx index d42f8e3d..844f8d83 100644 --- a/apps/registry/registry/default/map-timeline/map-timeline.tsx +++ b/apps/registry/registry/default/map-timeline/map-timeline.tsx @@ -552,7 +552,6 @@ function usePlayback(arguments_: { const next = yearRef.current + delta * speed; if (next >= endYear) { setYear(endYear); - setIsPlaying(false); return; } setYear(next); diff --git a/apps/registry/registry/default/progress-card/progress-card.tsx b/apps/registry/registry/default/progress-card/progress-card.tsx index e8d95999..b8d92bd9 100644 --- a/apps/registry/registry/default/progress-card/progress-card.tsx +++ b/apps/registry/registry/default/progress-card/progress-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useEffect, useState } from "react"; +import { memo, useMemo } from "react"; import type { ReactNode } from "react"; @@ -76,18 +76,11 @@ function ContentCardImpl({ tags = EMPTY_PROGRESS_CARD_LIST, title, }: ContentCardProps): React.ReactNode { - const [progress, setProgress] = useState(null); const isHydrated = useMounted(); - - // Load progress after hydration - useEffect(() => { - if (getProgress) { - const result = getProgress(); - requestAnimationFrame(() => { - setProgress(result); - }); - } - }, [getProgress]); + const progress = useMemo( + () => (isHydrated && getProgress ? getProgress() : null), + [getProgress, isHydrated], + ); const showProgress = isHydrated && progress && progress.completedCount > 0; diff --git a/apps/registry/registry/default/rating/rating.tsx b/apps/registry/registry/default/rating/rating.tsx index 856b7ccf..7889313c 100644 --- a/apps/registry/registry/default/rating/rating.tsx +++ b/apps/registry/registry/default/rating/rating.tsx @@ -5,6 +5,7 @@ import { useMemo, useState } from "react"; import { Star } from "lucide-react"; import type { ReactNode } from "react"; +import { useControllableState } from "@vllnt/ui"; import { cn } from "@vllnt/ui"; const sizeClasses = { @@ -113,20 +114,18 @@ export function Rating({ size = "md", value, }: RatingProps): ReactNode { - const isControlled = value !== undefined; - const [internalValue, setInternalValue] = useState(defaultValue); const [hoveredValue, setHoveredValue] = useState(0); - const activeValue = isControlled ? (value ?? 0) : internalValue; + const [activeValue, setActiveValue] = useControllableState({ + defaultValue, + onChange: onValueChange, + value, + }); const handleSelect = (nextValue: number): void => { const resolvedValue = allowClear && activeValue === nextValue ? 0 : nextValue; - if (!isControlled) { - setInternalValue(resolvedValue); - } - - onValueChange?.(resolvedValue); + setActiveValue(resolvedValue); }; return ( diff --git a/apps/registry/registry/default/search-bar/search-bar.tsx b/apps/registry/registry/default/search-bar/search-bar.tsx index bfa5b895..2ed33a0d 100644 --- a/apps/registry/registry/default/search-bar/search-bar.tsx +++ b/apps/registry/registry/default/search-bar/search-bar.tsx @@ -5,6 +5,7 @@ import { Suspense, useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useDebounce } from "@vllnt/ui"; +import { useEventCallback } from "@vllnt/ui"; import { Button } from "@vllnt/ui"; import { Input } from "@vllnt/ui"; @@ -58,6 +59,9 @@ function SearchBarInner({ const debouncedQuery = useDebounce(query, 300); const isInitialMount = useRef(true); const isUserTyping = useRef(false); + const runSearch = useEventCallback((searchQuery: string): void => { + onSearch?.(searchQuery); + }); const typingTimeoutReference = useRef(undefined); const lastSetSearchParameterReference = useRef(""); @@ -103,7 +107,7 @@ function SearchBarInner({ lastDebouncedQueryReference.current = trimmedQuery; if (onSearch) { - onSearch(trimmedQuery); + runSearch(trimmedQuery); return; } @@ -130,7 +134,7 @@ function SearchBarInner({ // hard redirects, not Next router.replace — but ESLint doesn't // know that rule, so we don't add an eslint-disable for it. router.replace(`?${newUrl}`); - }, [debouncedQuery, router, onSearch, searchParameters]); + }, [debouncedQuery, onSearch, router, runSearch, searchParameters]); // Cleanup timeout on unmount useEffect(() => { diff --git a/apps/registry/registry/default/sidebar/sidebar.tsx b/apps/registry/registry/default/sidebar/sidebar.tsx index 915e1f65..0ccebb8d 100644 --- a/apps/registry/registry/default/sidebar/sidebar.tsx +++ b/apps/registry/registry/default/sidebar/sidebar.tsx @@ -1,11 +1,18 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { ChevronDown } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useUncontrolledState } from "@vllnt/ui"; import { cn } from "@vllnt/ui"; import { useSidebar } from "@vllnt/ui"; @@ -25,26 +32,51 @@ type SidebarProps = { sections: SidebarSection[]; }; -function useMobile(setOpen: (open: boolean) => void) { - const [isMobile, setIsMobile] = useState(false); +function noop(): void { + return undefined; +} - useEffect(() => { - const checkMobile = () => { - const mobile = window.innerWidth < 1024; - setIsMobile(mobile); - if (mobile) { - setOpen(false); - } else { - setOpen(true); - } - }; +function getMobileSnapshot(): boolean { + return typeof window === "undefined" ? false : window.innerWidth < 1024; +} - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => { - window.removeEventListener("resize", checkMobile); - }; +function subscribeToResize( + onStoreChange: () => void, + onEnterMobile: () => void, +): () => void { + if (typeof window === "undefined") return noop; + + let previousIsMobile = getMobileSnapshot(); + const handleResize = (): void => { + const nextIsMobile = getMobileSnapshot(); + if (nextIsMobile && !previousIsMobile) { + onEnterMobile(); + } + previousIsMobile = nextIsMobile; + onStoreChange(); + }; + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; +} + +function useMobile(setOpen: (open: boolean) => void) { + const handleEnterMobile = useCallback(() => { + setOpen(false); }, [setOpen]); + const subscribe = useCallback( + (onStoreChange: () => void) => + subscribeToResize(onStoreChange, handleEnterMobile), + [handleEnterMobile], + ); + + const isMobile = useSyncExternalStore( + subscribe, + getMobileSnapshot, + () => false, + ); return isMobile; } @@ -88,7 +120,7 @@ function CollapsibleSection({ defaultOpen = true, title, }: CollapsibleSectionProps) { - const [isOpen, setIsOpen] = useState(defaultOpen); + const [isOpen, setIsOpen] = useUncontrolledState(defaultOpen); if (!collapsible) { return ( diff --git a/apps/registry/registry/default/slideshow/slideshow.tsx b/apps/registry/registry/default/slideshow/slideshow.tsx index 7f5914ce..f3ccf26e 100644 --- a/apps/registry/registry/default/slideshow/slideshow.tsx +++ b/apps/registry/registry/default/slideshow/slideshow.tsx @@ -5,6 +5,10 @@ import { memo, useCallback, useEffect, useState } from "react"; import type { ReactNode } from "react"; import { createPortal } from "react-dom"; +import { + useDocumentEventListener, + useEventCallback, +} from "@vllnt/ui"; import { useMounted } from "@vllnt/ui"; import { cn } from "@vllnt/ui"; import { CompletionDialog } from "@vllnt/ui"; @@ -153,8 +157,8 @@ function SlideshowImpl({ [currentIndex, goToSection], ); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent): void => { + const handleDocumentKeyDown = useEventCallback( + (event: KeyboardEvent): void => { if (isCompletionDialogOpen) return; if (event.key === "Escape") { event.preventDefault(); @@ -176,12 +180,10 @@ function SlideshowImpl({ event.preventDefault(); handlePrevious(); } - }; - document.addEventListener("keydown", handleKeyDown, true); - return () => { - document.removeEventListener("keydown", handleKeyDown, true); - }; - }, [handleNext, handlePrevious, onExit, isTocOpen, isCompletionDialogOpen]); + }, + ); + + useDocumentEventListener("keydown", handleDocumentKeyDown, true); if (!currentSection || !mounted) return null; diff --git a/apps/registry/registry/default/table-of-contents-panel/table-of-contents-panel.tsx b/apps/registry/registry/default/table-of-contents-panel/table-of-contents-panel.tsx index 2725ea8d..6b677e4b 100644 --- a/apps/registry/registry/default/table-of-contents-panel/table-of-contents-panel.tsx +++ b/apps/registry/registry/default/table-of-contents-panel/table-of-contents-panel.tsx @@ -4,6 +4,10 @@ import { memo, useEffect, useRef } from "react"; import type { ReactNode } from "react"; +import { + useEventCallback, + useWindowEventListener, +} from "@vllnt/ui"; import { cn } from "@vllnt/ui"; export type TOCSection = { @@ -47,25 +51,21 @@ function TableOfContentsPanelImpl({ }: TableOfContentsPanelProps): React.ReactNode { const panelRef = useRef(null); const closeButtonRef = useRef(null); + const handleEscapeKey = useEventCallback((event: KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + } + }); // Focus trap and close on Escape useEffect(() => { if (!isOpen) return; closeButtonRef.current?.focus(); + }, [isOpen]); - const handleKeyDown = (event: KeyboardEvent): void => { - if (event.key === "Escape") { - event.preventDefault(); - onClose(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [isOpen, onClose]); + useWindowEventListener("keydown", handleEscapeKey, { enabled: isOpen }); // Prevent body scroll when open useEffect(() => { diff --git a/apps/registry/registry/default/tabs/tabs.tsx b/apps/registry/registry/default/tabs/tabs.tsx index 428731a3..1b6606dc 100644 --- a/apps/registry/registry/default/tabs/tabs.tsx +++ b/apps/registry/registry/default/tabs/tabs.tsx @@ -1,9 +1,10 @@ "use client"; -import { createContext, useContext, useMemo, useState } from "react"; +import { createContext, useContext, useMemo } from "react"; import type { ReactNode } from "react"; +import { useUncontrolledState } from "@vllnt/ui"; import { cn } from "@vllnt/ui"; // Context for tabs state @@ -35,7 +36,7 @@ function Tabs({ defaultValue, onValueChange, }: TabsProps): React.ReactNode { - const [activeTab, setActiveTab] = useState(defaultValue); + const [activeTab, setActiveTab] = useUncontrolledState(defaultValue); const handleSetActiveTab = (value: string): void => { setActiveTab(value); diff --git a/apps/registry/registry/default/thinking-block/thinking-block.tsx b/apps/registry/registry/default/thinking-block/thinking-block.tsx index e1c38149..26792e02 100644 --- a/apps/registry/registry/default/thinking-block/thinking-block.tsx +++ b/apps/registry/registry/default/thinking-block/thinking-block.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useId, useState } from "react"; +import { useCallback, useId, useLayoutEffect, useReducer } from "react"; import { ChevronDown, ChevronRight } from "lucide-react"; @@ -14,26 +14,43 @@ export type ThinkingBlockProps = { thinking: string; }; +type ThinkingBlockState = { + isExpanded: boolean; +}; + +type ThinkingBlockAction = + | { isStreaming: boolean; type: "streaming-changed" } + | { type: "toggle" }; + +function thinkingBlockReducer( + state: ThinkingBlockState, + action: ThinkingBlockAction, +): ThinkingBlockState { + switch (action.type) { + case "streaming-changed": + return action.isStreaming ? { isExpanded: true } : state; + case "toggle": + return { isExpanded: !state.isExpanded }; + } +} + /** Collapsible thinking block with streaming support. */ export function ThinkingBlock({ className, isStreaming = false, thinking, }: ThinkingBlockProps) { - const [isExpanded, setIsExpanded] = useState(isStreaming); + const [{ isExpanded }, dispatch] = useReducer(thinkingBlockReducer, { + isExpanded: isStreaming, + }); const contentId = useId(); - // Auto-open when streaming starts - useEffect(() => { - if (isStreaming) { - requestAnimationFrame(() => { - setIsExpanded(true); - }); - } + useLayoutEffect(() => { + dispatch({ isStreaming, type: "streaming-changed" }); }, [isStreaming]); const toggleExpanded = useCallback(() => { - setIsExpanded((previous) => !previous); + dispatch({ type: "toggle" }); }, []); return ( diff --git a/apps/registry/registry/default/tldr-section/tldr-section.tsx b/apps/registry/registry/default/tldr-section/tldr-section.tsx index 5dfc0e37..8846bf79 100644 --- a/apps/registry/registry/default/tldr-section/tldr-section.tsx +++ b/apps/registry/registry/default/tldr-section/tldr-section.tsx @@ -1,16 +1,46 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useReducer, useRef } from "react"; type TLDRSectionProps = { children: React.ReactNode; label: string; }; +type TLDRSectionState = { + hasBeenOpened: boolean; + isExpanded: boolean; + isLoading: boolean; +}; + +type TLDRSectionAction = + | { type: "finish-loading" } + | { type: "mark-opened" } + | { type: "toggle" }; + +function tldrSectionReducer( + state: TLDRSectionState, + action: TLDRSectionAction, +): TLDRSectionState { + switch (action.type) { + case "finish-loading": + return { ...state, isLoading: false }; + case "mark-opened": + return { ...state, hasBeenOpened: true, isLoading: true }; + case "toggle": + return { ...state, isExpanded: !state.isExpanded }; + } +} + export function TLDRSection({ children, label }: TLDRSectionProps) { - const [isExpanded, setIsExpanded] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [hasBeenOpened, setHasBeenOpened] = useState(false); + const [{ hasBeenOpened, isExpanded, isLoading }, dispatch] = useReducer( + tldrSectionReducer, + { + hasBeenOpened: false, + isExpanded: false, + isLoading: false, + }, + ); const timerReference = useRef(null); useEffect(() => { @@ -21,13 +51,12 @@ export function TLDRSection({ children, label }: TLDRSectionProps) { } const rafId = requestAnimationFrame(() => { - setIsLoading(true); - setHasBeenOpened(true); + dispatch({ type: "mark-opened" }); }); // Simulate loading with skeleton timerReference.current = setTimeout(() => { - setIsLoading(false); + dispatch({ type: "finish-loading" }); timerReference.current = null; }, 800); @@ -53,7 +82,7 @@ export function TLDRSection({ children, label }: TLDRSectionProps) {