From 555e155d80e54727073728d99f70745519d43961 Mon Sep 17 00:00:00 2001 From: Denis Yakshov Date: Wed, 7 Oct 2026 16:37:53 +0200 Subject: [PATCH] feat(react,useRefState): add new hook --- .../AutocompleteMenu/AutocompleteMenu.tsx | 7 +-- .../FiltersFormGroup/FiltersFormGroup.tsx | 8 +-- .../SidebarScrollable/SidebarScrollable.tsx | 8 +-- packages/react/src/hooks/index.ts | 1 + packages/react/src/hooks/useRefState/index.ts | 1 + .../src/hooks/useRefState/useRefState.api.mdx | 27 +++++++++ .../hooks/useRefState/useRefState.stories.tsx | 60 +++++++++++++++++++ .../src/hooks/useRefState/useRefState.ts | 29 +++++++++ 8 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 packages/react/src/hooks/useRefState/index.ts create mode 100644 packages/react/src/hooks/useRefState/useRefState.api.mdx create mode 100644 packages/react/src/hooks/useRefState/useRefState.stories.tsx create mode 100644 packages/react/src/hooks/useRefState/useRefState.ts diff --git a/packages/react/src/components/AutocompleteMenu/AutocompleteMenu.tsx b/packages/react/src/components/AutocompleteMenu/AutocompleteMenu.tsx index 0ba93381b..59e2cd061 100644 --- a/packages/react/src/components/AutocompleteMenu/AutocompleteMenu.tsx +++ b/packages/react/src/components/AutocompleteMenu/AutocompleteMenu.tsx @@ -10,7 +10,6 @@ import { useImperativeHandle, useMemo, useRef, - useState, } from 'react'; import { AutocompleteMenuProps } from './AutocompleteMenu.types'; @@ -19,7 +18,7 @@ import { useOverlayScrollbars } from 'overlayscrollbars-react'; import clsx from 'clsx'; -import { useForkRef, useIntersectionObserver, useScrollLock } from '../../hooks'; +import { useForkRef, useIntersectionObserver, useRefState, useScrollLock } from '../../hooks'; import { IconCloseLineW350, IconMagnify2LineW400 } from '../../icons'; import { useDefaultProps } from '../../theming'; import { Button } from '../Button'; @@ -107,7 +106,7 @@ export const AutocompleteMenu = (({ ref, ...inProps }: AutocompleteMenuProps(null); const handleSearchInputRef = useForkRef(searchInputRef, SearchProps?.inputRef); const menuListRef = useRef(null); - const [sentinelRef, setSentinelRef] = useState(null); + const [sentinelRef, setSentinelRef] = useRefState(); const [initialize, instance] = useOverlayScrollbars({ options: OVERLAY_SCROLLBARS_OPTIONS, defer: true }); @@ -137,7 +136,7 @@ export const AutocompleteMenu = (({ ref, ...inProps }: AutocompleteMenuProps { + useIntersectionObserver(sentinelRef, (entries) => { if (onLoadMore && entries[0].isIntersecting) { onLoadMore(); } diff --git a/packages/react/src/components/Filters/FiltersFormGroup/FiltersFormGroup.tsx b/packages/react/src/components/Filters/FiltersFormGroup/FiltersFormGroup.tsx index acbaad819..adda6cd0e 100644 --- a/packages/react/src/components/Filters/FiltersFormGroup/FiltersFormGroup.tsx +++ b/packages/react/src/components/Filters/FiltersFormGroup/FiltersFormGroup.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Children, RefAttributes, useState } from 'react'; +import { Children, RefAttributes } from 'react'; import { FiltersFormGroupProps } from './FiltersFormGroup.types'; @@ -8,7 +8,7 @@ import { OverlayScrollbarsComponentRef } from 'overlayscrollbars-react'; import clsx from 'clsx'; -import { useBoolean, useScrollPosition } from '../../../hooks'; +import { useBoolean, useRefState, useScrollPosition } from '../../../hooks'; import { useDefaultProps } from '../../../theming'; import { Link } from '../../Link'; import { OverlayScrollbars } from '../../OverlayScrollbars'; @@ -26,9 +26,9 @@ export const FiltersFormGroup = ({ ref: inRef, ...inProps }: FiltersFormGroupPro // The viewport is published from `onInitialized` rather than read off the ref during render, which // would have made the observed element depend on a value that cannot trigger a re-render. - const [viewport, setViewport] = useState(null); + const [viewport, setViewport] = useRefState(); - const { isScrollableY, isAtTop, isAtBottom } = useScrollPosition({ current: viewport }); + const { isScrollableY, isAtTop, isAtBottom } = useScrollPosition(viewport); const onInitialized = (instance: NonNullable>) => { setViewport(instance.elements().viewport ?? null); diff --git a/packages/react/src/components/Sidebar/SidebarScrollable/SidebarScrollable.tsx b/packages/react/src/components/Sidebar/SidebarScrollable/SidebarScrollable.tsx index 19618a655..8394f347f 100644 --- a/packages/react/src/components/Sidebar/SidebarScrollable/SidebarScrollable.tsx +++ b/packages/react/src/components/Sidebar/SidebarScrollable/SidebarScrollable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { RefAttributes, useState } from 'react'; +import { RefAttributes } from 'react'; import { SidebarScrollableProps } from './SidebarScrollable.types'; @@ -8,7 +8,7 @@ import { OverlayScrollbarsComponentRef } from 'overlayscrollbars-react'; import clsx from 'clsx'; -import { useScrollPosition } from '../../../hooks'; +import { useRefState, useScrollPosition } from '../../../hooks'; import { useDefaultProps } from '../../../theming'; import { OverlayScrollbars } from '../..//OverlayScrollbars'; @@ -26,9 +26,9 @@ export const SidebarScrollable = ({ // The viewport is published from `onInitialized` rather than read off the ref during render, which // would have made the observed element depend on a value that cannot trigger a re-render. - const [viewport, setViewport] = useState(null); + const [viewport, setViewport] = useRefState(); - const { isScrollableY, isAtTop, isAtBottom } = useScrollPosition({ current: viewport }); + const { isScrollableY, isAtTop, isAtBottom } = useScrollPosition(viewport); const onInitialized = (instance: NonNullable>) => { setViewport(instance.elements().viewport ?? null); diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 20478b6e4..a571db68d 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -28,6 +28,7 @@ export type { UsePaginationItem, UsePaginationProps, UsePaginationResult } from export { usePagination } from './usePagination'; export { usePermission } from './usePermission'; export { usePreviousValue } from './usePreviousValue'; +export { useRefState } from './useRefState'; export { useResizeObserver } from './useResizeObserver'; export { useScrollDirection } from './useScrollDirection'; export { useScrollLock } from './useScrollLock'; diff --git a/packages/react/src/hooks/useRefState/index.ts b/packages/react/src/hooks/useRefState/index.ts new file mode 100644 index 000000000..8adfbbf71 --- /dev/null +++ b/packages/react/src/hooks/useRefState/index.ts @@ -0,0 +1 @@ +export { useRefState } from './useRefState'; diff --git a/packages/react/src/hooks/useRefState/useRefState.api.mdx b/packages/react/src/hooks/useRefState/useRefState.api.mdx new file mode 100644 index 000000000..29b680611 --- /dev/null +++ b/packages/react/src/hooks/useRefState/useRefState.api.mdx @@ -0,0 +1,27 @@ +import { Meta } from '@storybook/addon-docs/blocks'; +import LinkTo from '@storybook/addon-links/react'; +import { TableFunction } from '~storybook/components/TableFunction'; + + + +# useRefState API + +```js +import { useRefState } from '@esfront/react'; +``` + + + +
+ +
+ +## Demos + +
    +
  • + + useRefState + +
  • +
diff --git a/packages/react/src/hooks/useRefState/useRefState.stories.tsx b/packages/react/src/hooks/useRefState/useRefState.stories.tsx new file mode 100644 index 000000000..8fbd3bf56 --- /dev/null +++ b/packages/react/src/hooks/useRefState/useRefState.stories.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; + +import { Meta, StoryObj } from '@storybook/react-vite'; + +import { useRefState } from './useRefState'; + +import { useResizeObserver } from '../useResizeObserver'; + +const meta: Meta = { + tags: ['autodocs'], + title: 'Hooks/useRefState', + parameters: { + references: ['useRefState'], + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Demo: Story = { + render: function Render() { + const [isMounted, setMounted] = useState(false); + const [width, setWidth] = useState(0); + + // A plain `useRef` would not work here: the box is mounted after the first render, and writing + // to a ref does not re-render, so `useResizeObserver` would never pick the node up. + const [box, setBox] = useRefState(); + + useResizeObserver(box, (entries) => { + setWidth(entries[0].target.clientWidth); + }); + + return ( +
+ + {isMounted && ( +
+ Drag my bottom right corner +
+ )} +
Observed width: {width}px
+
+ ); + }, +}; diff --git a/packages/react/src/hooks/useRefState/useRefState.ts b/packages/react/src/hooks/useRefState/useRefState.ts new file mode 100644 index 000000000..9d31174f1 --- /dev/null +++ b/packages/react/src/hooks/useRefState/useRefState.ts @@ -0,0 +1,29 @@ +'use client'; + +import { RefObject, useCallback, useRef } from 'react'; + +import { useForceUpdate } from '../useForceUpdate'; + +/** + * The hook that keeps a value in a ref and re-renders the component whenever that value is set. Useful for feeding a + * DOM node to the hooks that observe `ref.current`, such as `useResizeObserver` or `useIntersectionObserver`, which a + * plain `useRef` cannot do because writing to it does not trigger a render. + * @param initialValue The value the ref is initialized with. + * @returns The ref and a setter which can be passed directly to the `ref` prop as a callback ref. + */ +export const useRefState = (initialValue: T | null = null): [RefObject, (value: T | null) => void] => { + const ref = useRef(initialValue); + const forceUpdate = useForceUpdate(); + + const setRef = useCallback( + (value: T | null) => { + if (ref.current !== value) { + ref.current = value; + forceUpdate(); + } + }, + [forceUpdate] + ); + + return [ref, setRef]; +};