diff --git a/README.md b/README.md index 1bec540..c7a83f4 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,14 @@ Compatible with React v18.0.0+. - [useDocumentTitle](https://usehooks.com/usedocumenttitle) - [useFavicon](https://usehooks.com/usefavicon) - [useGeolocation](https://usehooks.com/usegeolocation) -- [useHistoryState](https://usehooks.com/usehistoryState) +- [useHistoryState](https://usehooks.com/usehistorystate) - [useHover](https://usehooks.com/usehover) - [useIdle](https://usehooks.com/useidle) - [useIntersectionObserver](https://usehooks.com/useintersectionobserver) - [useIsClient](https://usehooks.com/useisclient) - [useIsFirstRender](https://usehooks.com/useisfirstrender) - [useList](https://usehooks.com/uselist) +- [useLocalStorage](https://usehooks.com/uselocalstorage) - [useLockBodyScroll](https://usehooks.com/uselockbodyscroll) - [useLongPress](https://usehooks.com/uselongpress) - [useMap](https://usehooks.com/usemap) @@ -45,6 +46,7 @@ Compatible with React v18.0.0+. - [useRenderCount](https://usehooks.com/userendercount) - [useRenderInfo](https://usehooks.com/userenderinfo) - [useScript](https://usehooks.com/usescript) +- [useSessionStorage](https://usehooks.com/usesessionstorage) - [useSet](https://usehooks.com/useset) - [useThrottle](https://usehooks.com/usethrottle) - [useToggle](https://usehooks.com/usetoggle) @@ -67,9 +69,7 @@ Compatible with React v18.0.0+. - [useInterval](https://usehooks.com/useinterval) - [useIntervalWhen](https://usehooks.com/useintervalwhen) - [useKeyPress](https://usehooks.com/usekeypress) -- [useLocalStorage](https://usehooks.com/uselocalstorage) - [useLogger](https://usehooks.com/uselogger) - [usePageLeave](https://usehooks.com/usepageleave) - [useRandomInterval](https://usehooks.com/userandominterval) -- [useSessionStorage](https://usehooks.com/usesessionstorage) - [useTimeout](https://usehooks.com/usetimeout) diff --git a/index.d.ts b/index.d.ts index 8e27f47..db01dc2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -82,6 +82,7 @@ export type CustomQueue = { first: T | undefined; last: T | undefined; size: number; + queue: T[]; }; export type RenderInfo = { @@ -123,10 +124,7 @@ declare module "@uidotdev/usehooks" { ): React.MutableRefObject; export function useCopyToClipboard(): [ - { - error: Error | null; - text: string | null; - }, + string | null, (value: string) => Promise ]; @@ -162,15 +160,15 @@ declare module "@uidotdev/usehooks" { export function useHistoryState(initialPresent?: T): HistoryState; export function useHover(): [ - React.MutableRefObject, + React.RefCallback, boolean ]; export function useIdle(ms?: number): boolean; - export function useIntersectionObserver( + export function useIntersectionObserver( options?: IntersectionObserverInit - ): [React.MutableRefObject, IntersectionObserverEntry | null]; + ): [React.RefCallback, IntersectionObserverEntry | null]; export function useIsClient(): boolean; @@ -178,6 +176,11 @@ declare module "@uidotdev/usehooks" { export function useList(defaultList?: T[]): [T[], CustomList]; + export function useLocalStorage( + key: string, + initialValue?: T + ): [T, React.Dispatch>]; + export function useLockBodyScroll(): void; export function useLongPress( @@ -188,7 +191,7 @@ declare module "@uidotdev/usehooks" { export function useMap(initialState?: T): Map; export function useMeasure(): [ - React.MutableRefObject, + React.RefCallback, { width: number | null; height: number | null; @@ -226,7 +229,12 @@ declare module "@uidotdev/usehooks" { options?: { removeOnUnmount?: boolean; } - ): "idle" | "loading" | "ready" | "error"; + ): "unknown" | "loading" | "ready" | "error"; + + export function useSessionStorage( + key: string, + initialValue: T + ): [T, React.Dispatch>]; export function useSet(values?: T[]): Set; diff --git a/index.js b/index.js index ef502e4..f6e4fe2 100644 --- a/index.js +++ b/index.js @@ -38,6 +38,14 @@ function throttle(cb, ms) { }; } +function isPlainObject(value) { + return Object.prototype.toString.call(value) === "[object Object]"; +} + +function dispatchStorageEvent(key, newValue) { + window.dispatchEvent(new StorageEvent("storage", { key, newValue })); +} + export function useBattery() { const [state, setState] = React.useState({ supported: true, @@ -122,35 +130,34 @@ export function useClickAway(cb) { return ref; } -export function useCopyToClipboard() { - const [state, setState] = React.useState({ - error: null, - text: null, - }); - - const copyToClipboard = React.useCallback(async (value) => { - if (!navigator?.clipboard) { - return setState({ - error: new Error("Clipboard not supported"), - text: null, - }); - } - - const handleSuccess = () => { - setState({ - error: null, - text: value, - }); - }; +function oldSchoolCopy(text) { + const tempTextArea = document.createElement("textarea"); + tempTextArea.value = text; + document.body.appendChild(tempTextArea); + tempTextArea.select(); + document.execCommand("copy"); + document.body.removeChild(tempTextArea); +} - const handleFailure = (e) => { - setState({ - error: e, - text: null, - }); +export function useCopyToClipboard() { + const [state, setState] = React.useState(null); + + const copyToClipboard = React.useCallback((value) => { + const handleCopy = async () => { + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + setState(value); + } else { + throw new Error("writeText not supported"); + } + } catch (e) { + oldSchoolCopy(value); + setState(value); + } }; - navigator.clipboard.writeText(value).then(handleSuccess, handleFailure); + handleCopy(); }, []); return [state, copyToClipboard]; @@ -159,13 +166,13 @@ export function useCopyToClipboard() { export function useCounter(startingValue = 0, options = {}) { const { min, max } = options; - if (min && startingValue < min) { + if (typeof min === "number" && startingValue < min) { throw new Error( `Your starting value of ${startingValue} is less than your min of ${min}.` ); } - if (max && startingValue > max) { + if (typeof max === "number" && startingValue > max) { throw new Error( `Your starting value of ${startingValue} is greater than your max of ${max}.` ); @@ -173,47 +180,50 @@ export function useCounter(startingValue = 0, options = {}) { const [count, setCount] = React.useState(startingValue); - const increment = () => { - const nextCount = count + 1; - if (max && nextCount > max) { - return; - } + const increment = React.useCallback(() => { + setCount((c) => { + const nextCount = c + 1; - setCount(nextCount); - }; + if (typeof max === "number" && nextCount > max) { + return c; + } - const decrement = () => { - const nextCount = count - 1; - if (min && nextCount < min) { - return; - } + return nextCount; + }); + }, [max]); - setCount(nextCount); - }; + const decrement = React.useCallback(() => { + setCount((c) => { + const nextCount = c - 1; - const set = (nextCount) => { - if (max && nextCount > max) { - return; - } + if (typeof min === "number" && nextCount < min) { + return c; + } - if (min && nextCount < min) { - return; - } + return nextCount; + }); + }, [min]); - if (nextCount === count) { - return; - } + const set = React.useCallback( + (nextCount) => { + setCount((c) => { + if (typeof max === "number" && nextCount > max) { + return c; + } - setCount(nextCount); - }; + if (typeof min === "number" && nextCount < min) { + return c; + } - const reset = () => { - if (count === startingValue) { - return; - } + return nextCount; + }); + }, + [max, min] + ); + const reset = React.useCallback(() => { setCount(startingValue); - }; + }, [startingValue]); return [ count, @@ -260,13 +270,17 @@ export function useDocumentTitle(title) { export function useFavicon(url) { React.useEffect(() => { - const link = - document.querySelector("link[rel*='icon']") || - document.createElement("link"); - link.type = "image/x-icon"; - link.rel = "shortcut icon"; - link.href = url; - document.getElementsByTagName("head")[0].appendChild(link); + let link = document.querySelector(`link[rel~="icon"]`); + + if (!link) { + link = document.createElement("link"); + link.type = "image/x-icon"; + link.rel = "icon"; + link.href = url; + document.head.appendChild(link); + } else { + link.href = url; + } }, [url]); } @@ -337,38 +351,38 @@ const initialUseHistoryStateState = { const useHistoryStateReducer = (state, action) => { const { past, present, future } = state; - switch (action.type) { - case "UNDO": - return { - past: past.slice(0, past.length - 1), - present: past[past.length - 1], - future: [present, ...future], - }; - case "REDO": - return { - past: [...past, present], - present: future[0], - future: future.slice(1), - }; - case "SET": - const { newPresent } = action; - if (action.newPresent === present) { - return state; - } + if (action.type === "UNDO") { + return { + past: past.slice(0, past.length - 1), + present: past[past.length - 1], + future: [present, ...future], + }; + } else if (action.type === "REDO") { + return { + past: [...past, present], + present: future[0], + future: future.slice(1), + }; + } else if (action.type === "SET") { + const { newPresent } = action; - return { - past: [...past, present], - present: newPresent, - future: [], - }; - case "CLEAR": - return { - ...initialUseHistoryStateState, - present: action.initialPresent, - }; - default: - throw new Error("Unsupported action type"); + if (action.newPresent === present) { + return state; + } + + return { + past: [...past, present], + present: newPresent, + future: [], + }; + } else if (action.type === "CLEAR") { + return { + ...initialUseHistoryStateState, + present: action.initialPresent, + }; + } else { + throw new Error("Unsupported action type"); } }; @@ -411,31 +425,40 @@ export function useHistoryState(initialPresent = {}) { export function useHover() { const [hovering, setHovering] = React.useState(false); - const ref = React.useRef(null); - - React.useEffect(() => { - const node = ref.current; + const previousNode = React.useRef(null); - if (!node) return; + const handleMouseEnter = React.useCallback(() => { + setHovering(true); + }, []); - const handleMouseEnter = () => { - setHovering(true); - }; + const handleMouseLeave = React.useCallback(() => { + setHovering(false); + }, []); - const handleMouseLeave = () => { - setHovering(false); - }; + const customRef = React.useCallback( + (node) => { + if (previousNode.current?.nodeType === Node.ELEMENT_NODE) { + previousNode.current.removeEventListener( + "mouseenter", + handleMouseEnter + ); + previousNode.current.removeEventListener( + "mouseleave", + handleMouseLeave + ); + } - node.addEventListener("mouseenter", handleMouseEnter); - node.addEventListener("mouseleave", handleMouseLeave); + if (node?.nodeType === Node.ELEMENT_NODE) { + node.addEventListener("mouseenter", handleMouseEnter); + node.addEventListener("mouseleave", handleMouseLeave); + } - return () => { - node.removeEventListener("mouseenter", handleMouseEnter); - node.removeEventListener("mouseleave", handleMouseLeave); - }; - }, []); + previousNode.current = node; + }, + [handleMouseEnter, handleMouseLeave] + ); - return [ref, hovering]; + return [customRef, hovering]; } export function useIdle(ms = 1000 * 60) { @@ -487,33 +510,34 @@ export function useIdle(ms = 1000 * 60) { } export function useIntersectionObserver(options = {}) { - const { threshold = 1, root = null, rootMargin = "0%" } = options; - const ref = React.useRef(null); + const { threshold = 1, root = null, rootMargin = "0px" } = options; const [entry, setEntry] = React.useState(null); - React.useEffect(() => { - const node = ref?.current; - - if (!node || typeof IntersectionObserver !== "function") { - return; - } + const previousObserver = React.useRef(null); - const observer = new IntersectionObserver( - ([entry]) => { - setEntry(entry); - }, - { threshold, root, rootMargin } - ); + const customRef = React.useCallback( + (node) => { + if (previousObserver.current) { + previousObserver.current.disconnect(); + previousObserver.current = null; + } - observer.observe(node); + if (node?.nodeType === Node.ELEMENT_NODE) { + const observer = new IntersectionObserver( + ([entry]) => { + setEntry(entry); + }, + { threshold, root, rootMargin } + ); - return () => { - setEntry(null); - observer.disconnect(); - }; - }, [threshold, root, rootMargin]); + observer.observe(node); + previousObserver.current = observer; + } + }, + [threshold, root, rootMargin] + ); - return [ref, entry]; + return [customRef, entry]; } export function useIsClient() { @@ -540,44 +564,95 @@ export function useIsFirstRender() { export function useList(defaultList = []) { const [list, setList] = React.useState(defaultList); - const methods = React.useMemo(() => { - const set = (l) => { - setList(l); - }; + const set = React.useCallback((l) => { + setList(l); + }, []); - const push = (element) => { - setList((l) => [...l, element]); - }; + const push = React.useCallback((element) => { + setList((l) => [...l, element]); + }, []); - const removeAt = (index) => { - setList((l) => [...l.slice(0, index), ...l.slice(index + 1)]); - }; + const removeAt = React.useCallback((index) => { + setList((l) => [...l.slice(0, index), ...l.slice(index + 1)]); + }, []); - const insertAt = (index, element) => { - setList((l) => [...l.slice(0, index), element, ...l.slice(index)]); - }; + const insertAt = React.useCallback((index, element) => { + setList((l) => [...l.slice(0, index), element, ...l.slice(index)]); + }, []); - const updateAt = (index, element) => { - setList((l) => l.map((e, i) => (i === index ? element : e))); - }; + const updateAt = React.useCallback((index, element) => { + setList((l) => l.map((e, i) => (i === index ? element : e))); + }, []); - const clear = () => setList([]); + const clear = React.useCallback(() => setList([]), []); - return { - set, - push, - removeAt, - insertAt, - updateAt, - clear, - }; - }, []); + return [list, { set, push, removeAt, insertAt, updateAt, clear }]; +} + +const setLocalStorageItem = (key, value) => { + const stringifiedValue = JSON.stringify(value); + window.localStorage.setItem(key, stringifiedValue); + dispatchStorageEvent(key, stringifiedValue); +}; + +const removeLocalStorageItem = (key) => { + window.localStorage.removeItem(key); + dispatchStorageEvent(key, null); +}; + +const getLocalStorageItem = (key) => { + return window.localStorage.getItem(key); +}; + +const useLocalStorageSubscribe = (callback) => { + window.addEventListener("storage", callback); + return () => window.removeEventListener("storage", callback); +}; - return [list, methods]; +const getLocalStorageServerSnapshot = () => { + throw Error("useLocalStorage is a client-only hook"); +}; + +export function useLocalStorage(key, initialValue) { + const getSnapshot = () => getLocalStorageItem(key); + + const store = React.useSyncExternalStore( + useLocalStorageSubscribe, + getSnapshot, + getLocalStorageServerSnapshot + ); + + const setState = React.useCallback( + (v) => { + try { + const nextState = typeof v === "function" ? v(JSON.parse(store)) : v; + + if (nextState === undefined || nextState === null) { + removeLocalStorageItem(key); + } else { + setLocalStorageItem(key, nextState); + } + } catch (e) { + console.warn(e); + } + }, + [key, store] + ); + + React.useEffect(() => { + if ( + getLocalStorageItem(key) === null && + typeof initialValue !== "undefined" + ) { + setLocalStorageItem(key, initialValue); + } + }, [key, initialValue]); + + return [store ? JSON.parse(store) : initialValue, setState]; } export function useLockBodyScroll() { - React.useEffect(() => { + React.useLayoutEffect(() => { const originalStyle = window.getComputedStyle(document.body).overflow; document.body.style.overflow = "hidden"; return () => { @@ -586,23 +661,18 @@ export function useLockBodyScroll() { }, []); } -export function useLongPress( - callback, - { threshold = 400, onStart, onFinish, onCancel } = {} -) { +export function useLongPress(callback, options = {}) { + const { threshold = 400, onStart, onFinish, onCancel } = options; const isLongPressActive = React.useRef(false); const isPressed = React.useRef(false); const timerId = React.useRef(); - const cbRef = React.useRef(callback); - React.useLayoutEffect(() => { - cbRef.current = callback; - }); - - const start = React.useCallback( - () => (event) => { - if (isPressed.current) return; + return React.useMemo(() => { + if (typeof callback !== "function") { + return {}; + } + const start = (event) => { if (!isMouseEvent(event) && !isTouchEvent(event)) return; if (onStart) { @@ -611,15 +681,12 @@ export function useLongPress( isPressed.current = true; timerId.current = setTimeout(() => { - cbRef.current(event); + callback(event); isLongPressActive.current = true; }, threshold); - }, - [onStart, threshold] - ); + }; - const cancel = React.useCallback( - () => (event) => { + const cancel = (event) => { if (!isMouseEvent(event) && !isTouchEvent(event)) return; if (isLongPressActive.current) { @@ -638,31 +705,24 @@ export function useLongPress( if (timerId.current) { window.clearTimeout(timerId.current); } - }, - [onFinish, onCancel] - ); - - return React.useMemo(() => { - if (callback === null) { - return {}; - } + }; const mouseHandlers = { - onMouseDown: start(), - onMouseUp: cancel(), - onMouseLeave: cancel(), + onMouseDown: start, + onMouseUp: cancel, + onMouseLeave: cancel, }; const touchHandlers = { - onTouchStart: start(), - onTouchEnd: cancel(), + onTouchStart: start, + onTouchEnd: cancel, }; return { ...mouseHandlers, ...touchHandlers, }; - }, [callback, cancel, start]); + }, [callback, threshold, onCancel, onFinish, onStart]); } export function useMap(initialState) { @@ -691,31 +751,35 @@ export function useMap(initialState) { } export function useMeasure() { - const ref = React.useRef(null); - const [rect, setRect] = React.useState({ + const [dimensions, setDimensions] = React.useState({ width: null, height: null, }); - React.useLayoutEffect(() => { - if (!ref.current) return; - - const observer = new ResizeObserver(([entry]) => { - if (entry && entry.contentRect) { - setRect({ - width: entry.contentRect.width, - height: entry.contentRect.height, - }); - } - }); + const previousObserver = React.useRef(null); - observer.observe(ref.current); - return () => { - observer.disconnect(); - }; + const customRef = React.useCallback((node) => { + if (previousObserver.current) { + previousObserver.current.disconnect(); + previousObserver.current = null; + } + + if (node?.nodeType === Node.ELEMENT_NODE) { + const observer = new ResizeObserver(([entry]) => { + if (entry && entry.borderBoxSize) { + const { inlineSize: width, blockSize: height } = + entry.borderBoxSize[0]; + + setDimensions({ width, height }); + } + }); + + observer.observe(node); + previousObserver.current = observer; + } }, []); - return [ref, rect]; + return [customRef, dimensions]; } export function useMediaQuery(query) { @@ -761,15 +825,13 @@ export function useMouse() { y: event.pageY, }; - if (ref.current instanceof HTMLElement) { + if (ref.current?.nodeType === Node.ELEMENT_NODE) { const { left, top } = ref.current.getBoundingClientRect(); - const elementPositionX = left + window.pageXOffset; - const elementPositionY = top + window.pageYOffset; + const elementPositionX = left + window.scrollX; + const elementPositionY = top + window.scrollY; const elementX = event.pageX - elementPositionX; const elementY = event.pageY - elementPositionY; - newState.elementX = elementX; - newState.elementY = elementY; newState.elementX = elementX; newState.elementY = elementY; newState.elementPositionX = elementPositionX; @@ -794,34 +856,44 @@ export function useMouse() { return [state, ref]; } -export function useNetworkState() { - const connection = +const getConnection = () => { + return ( navigator?.connection || navigator?.mozConnection || - navigator?.webkitConnection; + navigator?.webkitConnection + ); +}; - const cache = React.useRef({}); +const useNetworkStateSubscribe = (callback) => { + window.addEventListener("online", callback, { passive: true }); + window.addEventListener("offline", callback, { passive: true }); + + const connection = getConnection(); + + if (connection) { + connection.addEventListener("change", callback, { passive: true }); + } - const subscribe = React.useCallback((callback) => { - window.addEventListener("online", callback, { passive: true }); - window.addEventListener("offline", callback, { passive: true }); + return () => { + window.removeEventListener("online", callback); + window.removeEventListener("offline", callback); if (connection) { - connection.addEventListener("change", callback, { passive: true }); + connection.removeEventListener("change", callback); } + }; +}; - return () => { - window.removeEventListener("online", callback); - window.removeEventListener("offline", callback); +const getNetworkStateServerSnapshot = () => { + throw Error("useNetworkState is a client-only hook"); +}; - if (connection) { - connection.removeEventListener("change", callback); - } - }; - }, []); +export function useNetworkState() { + const cache = React.useRef({}); const getSnapshot = () => { const online = navigator.onLine; + const connection = getConnection(); const nextState = { online, @@ -841,11 +913,11 @@ export function useNetworkState() { } }; - const getServerSnapshot = () => { - throw Error("useNetworkState is a client-only hook"); - }; - - return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return React.useSyncExternalStore( + useNetworkStateSubscribe, + getSnapshot, + getNetworkStateServerSnapshot + ); } export function useObjectState(initialValue) { @@ -856,14 +928,16 @@ export function useObjectState(initialValue) { setState((s) => { const newState = arg(s); - return { - ...s, - ...newState, - }; + if (isPlainObject(newState)) { + return { + ...s, + ...newState, + }; + } }); } - if (typeof arg === "object") { + if (isPlainObject(arg)) { setState((s) => ({ ...s, ...arg, @@ -940,14 +1014,16 @@ export function usePreferredLanguage() { ); } -export function usePrevious(newValue) { - const previousRef = React.useRef(); +export function usePrevious(value) { + const [current, setCurrent] = React.useState(value); + const [previous, setPrevious] = React.useState(null); - React.useEffect(() => { - previousRef.current = newValue; - }); + if (value !== current) { + setPrevious(current); + setCurrent(value); + } - return previousRef.current; + return previous; } export function useQueue(initialValue = []) { @@ -1019,74 +1095,121 @@ export function useRenderInfo(name = "Unknown") { } export function useScript(src, options = {}) { - const [status, setStatus] = React.useState(() => { - if (!src) { - return "idle"; - } - - return "loading"; - }); - - const cachedScriptStatuses = React.useRef({}); + const [status, setStatus] = React.useState("loading"); + const optionsRef = React.useRef(options); React.useEffect(() => { - if (!src) { - return; - } + let script = document.querySelector(`script[src="${src}"]`); - const cachedScriptStatus = cachedScriptStatuses.current[src]; - if (cachedScriptStatus === "ready" || cachedScriptStatus === "error") { - setStatus(cachedScriptStatus); + const domStatus = script?.getAttribute("data-status"); + if (domStatus) { + setStatus(domStatus); return; } - let script = document.querySelector(`script[src="${src}"]`); - - if (script) { - setStatus( - script.getAttribute("data-status") ?? cachedScriptStatus ?? "loading" - ); - } else { + if (script === null) { script = document.createElement("script"); script.src = src; script.async = true; script.setAttribute("data-status", "loading"); document.body.appendChild(script); - const setAttributeFromEvent = (event) => { - const scriptStatus = event.type === "load" ? "ready" : "error"; + const handleScriptLoad = () => { + script.setAttribute("data-status", "ready"); + setStatus("ready"); + removeEventListeners(); + }; - if (script) { - script.setAttribute("data-status", scriptStatus); - } + const handleScriptError = () => { + script.setAttribute("data-status", "error"); + setStatus("error"); + removeEventListeners(); + }; + + const removeEventListeners = () => { + script.removeEventListener("load", handleScriptLoad); + script.removeEventListener("error", handleScriptError); }; - script.addEventListener("load", setAttributeFromEvent); - script.addEventListener("error", setAttributeFromEvent); + script.addEventListener("load", handleScriptLoad); + script.addEventListener("error", handleScriptError); + + const removeOnUnmount = optionsRef.current.removeOnUnmount; + + return () => { + if (removeOnUnmount === true) { + script.remove(); + removeEventListeners(); + } + }; + } else { + setStatus("unknown"); } + }, [src]); - const setStateFromEvent = (event) => { - const newStatus = event.type === "load" ? "ready" : "error"; - setStatus(newStatus); - cachedScriptStatuses.current[src] = newStatus; - }; + return status; +} - script.addEventListener("load", setStateFromEvent); - script.addEventListener("error", setStateFromEvent); +const setSessionStorageItem = (key, value) => { + const stringifiedValue = JSON.stringify(value); + window.sessionStorage.setItem(key, stringifiedValue); + dispatchStorageEvent(key, stringifiedValue); +}; - return () => { - if (script) { - script.removeEventListener("load", setStateFromEvent); - script.removeEventListener("error", setStateFromEvent); - } +const removeSessionStorageItem = (key) => { + window.sessionStorage.removeItem(key); + dispatchStorageEvent(key, null); +}; + +const getSessionStorageItem = (key) => { + return window.sessionStorage.getItem(key); +}; + +const useSessionStorageSubscribe = (callback) => { + window.addEventListener("storage", callback); + return () => window.removeEventListener("storage", callback); +}; + +const getSessionStorageServerSnapshot = () => { + throw Error("useSessionStorage is a client-only hook"); +}; + +export function useSessionStorage(key, initialValue) { + const getSnapshot = () => getSessionStorageItem(key); + + const store = React.useSyncExternalStore( + useSessionStorageSubscribe, + getSnapshot, + getSessionStorageServerSnapshot + ); + + const setState = React.useCallback( + (v) => { + try { + const nextState = typeof v === "function" ? v(JSON.parse(store)) : v; - if (script && options.removeOnUnmount) { - script.remove(); + if (nextState === undefined || nextState === null) { + removeSessionStorageItem(key); + } else { + setSessionStorageItem(key, nextState); + } + } catch (e) { + console.warn(e); } - }; - }, [src, options.removeOnUnmount]); + }, + [key, store] + ); - return status; + React.useEffect(() => { + if ( + getSessionStorageItem(key) === null && + typeof initialValue !== "undefined" + ) { + setSessionStorageItem(key, initialValue); + } + }, [key, initialValue]); + + return [store ? JSON.parse(store) : initialValue, setState]; } export function useSet(values) { @@ -1115,65 +1238,14 @@ export function useSet(values) { return setRef.current; } -export function useSpeech(text, options) { - const [state, setState] = React.useState(() => { - const { lang = "default", name = "" } = options.voice || {}; - return { - isPlaying: false, - status: "init", - lang: options.lang || "default", - voiceInfo: { lang, name }, - rate: options.rate || 1, - pitch: options.pitch || 1, - volume: options.volume || 1, - }; - }); - - const optionsRef = React.useRef(options); - - React.useEffect(() => { - const handlePlay = () => { - setState((s) => { - return { ...s, isPlaying: true, status: "play" }; - }); - }; - - const handlePause = () => { - setState((s) => { - return { ...s, isPlaying: false, status: "pause" }; - }); - }; - - const handleEnd = () => { - setState((s) => { - return { ...s, isPlaying: false, status: "end" }; - }); - }; - - const utterance = new SpeechSynthesisUtterance(text); - optionsRef.current.lang && (utterance.lang = optionsRef.current.lang); - optionsRef.current.voice && (utterance.voice = optionsRef.current.voice); - utterance.rate = optionsRef.current.rate || 1; - utterance.pitch = optionsRef.current.pitch || 1; - utterance.volume = optionsRef.current.volume || 1; - utterance.onstart = handlePlay; - utterance.onpause = handlePause; - utterance.onresume = handlePlay; - utterance.onend = handleEnd; - window.speechSynthesis.speak(utterance); - }, [text]); - - return state; -} - export function useThrottle(value, interval = 500) { const [throttledValue, setThrottledValue] = React.useState(value); - const lastUpdated = React.useRef(); + const lastUpdated = React.useRef(null); React.useEffect(() => { const now = Date.now(); - if (now >= lastUpdated.current + interval) { + if (lastUpdated.current && now >= lastUpdated.current + interval) { lastUpdated.current = now; setThrottledValue(value); } else { @@ -1190,7 +1262,13 @@ export function useThrottle(value, interval = 500) { } export function useToggle(initialValue) { - const [on, setOn] = React.useState(initialValue); + const [on, setOn] = React.useState(() => { + if (typeof initialValue === "boolean") { + return initialValue; + } + + return Boolean(initialValue); + }); const handleToggle = React.useCallback((value) => { if (typeof value === "boolean") { @@ -1203,27 +1281,30 @@ export function useToggle(initialValue) { return [on, handleToggle]; } -export function useVisibilityChange() { - const [documentVisible, setDocumentVisibility] = React.useState(true); +const useVisibilityChangeSubscribe = (callback) => { + document.addEventListener("visibilitychange", callback); - React.useEffect(() => { - const handleChange = () => { - if (document.visibilityState !== "visible") { - setDocumentVisibility(false); - } else { - setDocumentVisibility(true); - } - }; - handleChange(); + return () => { + document.removeEventListener("visibilitychange", callback); + }; +}; - document.addEventListener("visibilitychange", handleChange); +const getVisibilityChangeSnapshot = () => { + return document.visibilityState; +}; - return () => { - document.removeEventListener("visibilitychange", handleChange); - }; - }, []); +const getVisibilityChangeServerSnapshot = () => { + throw Error("useVisibilityChange is a client-only hook"); +}; + +export function useVisibilityChange() { + const visibilityState = React.useSyncExternalStore( + useVisibilityChangeSubscribe, + getVisibilityChangeSnapshot, + getVisibilityChangeServerSnapshot + ); - return documentVisible; + return visibilityState === "visible"; } export function useWindowScroll() { @@ -1246,7 +1327,7 @@ export function useWindowScroll() { React.useLayoutEffect(() => { const handleScroll = () => { - setState({ x: window.pageXOffset, y: window.pageYOffset }); + setState({ x: window.scrollX, y: window.scrollY }); }; handleScroll(); diff --git a/package-lock.json b/package-lock.json index 48532c0..d920bd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@uidotdev/usehooks", - "version": "2.1.0", + "version": "2.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@uidotdev/usehooks", - "version": "2.1.0", + "version": "2.4.1", "license": "MIT", "devDependencies": { "@types/react": "^18.2.20", diff --git a/package.json b/package.json index e33034d..12908aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@uidotdev/usehooks", - "version": "2.1.0", + "version": "2.4.1", "description": "A collection of modern, server-safe React hooks – from the ui.dev team", "type": "module", "repository": "uidotdev/usehooks", diff --git a/usehooks.com/public/img/banner-sale-reactgg.svg b/usehooks.com/public/img/banner-sale-reactgg.svg new file mode 100644 index 0000000..9aee0dd --- /dev/null +++ b/usehooks.com/public/img/banner-sale-reactgg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/usehooks.com/public/img/bytes-tshirt.png b/usehooks.com/public/img/bytes-tshirt.png new file mode 100644 index 0000000..5f59d9c Binary files /dev/null and b/usehooks.com/public/img/bytes-tshirt.png differ diff --git a/usehooks.com/public/img/react-gg-logo-sticker.svg b/usehooks.com/public/img/react-gg-logo-sticker.svg new file mode 100644 index 0000000..3e6f58a --- /dev/null +++ b/usehooks.com/public/img/react-gg-logo-sticker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/usehooks.com/src/components/CountdownTimer.tsx b/usehooks.com/src/components/CountdownTimer.tsx new file mode 100644 index 0000000..72ea4d4 --- /dev/null +++ b/usehooks.com/src/components/CountdownTimer.tsx @@ -0,0 +1,89 @@ +import { Fragment, useEffect, useState } from "react"; + +interface CountdownProps { + targetDate: string; // YYYY-MM-DD format +} + +interface TimeLeft { + days: number; + hours: number; + minutes: number; + seconds: number; +} + +function calculateTimeLeft(targetDate: string): TimeLeft { + const target = new Date(`${targetDate}T00:00:00-08:00`); + const now = new Date(); + const difference = +target - +now; + + if (difference <= 0) { + return { + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }; + } + + return { + days: Math.floor(difference / (1000 * 60 * 60 * 24)), + hours: Math.floor((difference / (1000 * 60 * 60)) % 24), + minutes: Math.floor((difference / 1000 / 60) % 60), + seconds: Math.floor((difference / 1000) % 60), + }; +} + +const formatNumber = (number: number) => number.toString().padStart(2, "0"); + +const Countdown: React.FC = ({ targetDate }) => { + const [timeLeft, setTimeLeft] = useState( + calculateTimeLeft(targetDate) + ); + + useEffect(() => { + const timer = setInterval(() => { + const newTimeLeft = calculateTimeLeft(targetDate); + setTimeLeft(newTimeLeft); + if ( + newTimeLeft.days === 0 && + newTimeLeft.hours === 0 && + newTimeLeft.minutes === 0 && + newTimeLeft.seconds === 0 + ) { + clearInterval(timer); + } + }, 1000); + + return () => clearInterval(timer); + }, [targetDate]); + + if ( + timeLeft.days === 0 && + timeLeft.hours === 0 && + timeLeft.minutes === 0 && + timeLeft.seconds === 0 + ) { + return null; + } + + return ( +
+ {["days", "hours", "minutes", "seconds"].map((unit, index) => ( + + {index > 0 && :} +
+ + {formatNumber(timeLeft[unit as keyof TimeLeft]).charAt(0)} + + + {formatNumber(timeLeft[unit as keyof TimeLeft]).charAt(1)} + +

{unit}

+
+
+ ))} +
+ ); +}; + +export default Countdown; diff --git a/usehooks.com/src/components/HookDescription.astro b/usehooks.com/src/components/HookDescription.astro index 0206b02..3ca4182 100644 --- a/usehooks.com/src/components/HookDescription.astro +++ b/usehooks.com/src/components/HookDescription.astro @@ -16,11 +16,15 @@ const { name } = Astro.props; class="logo" alt="React.gg" /> -

Want to learn how to build {name} yourself? Check out react.gg – the interactive way to master modern React.

+

+ Want to learn how to build {name} yourself? Check out react.gg – the interactive way to master modern React. +