Created
January 20, 2026 01:25
-
-
Save composite/cba75ced96dc559493344650c79f0fd8 to your computer and use it in GitHub Desktop.
useClock* - fires every, even of your time react hook, no dependencies required other than react, requires react 18 or later. AI Generated. MIT License.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useEffect, act } from 'react'; | |
| import { createRoot } from 'react-dom/client'; | |
| import { afterEach, describe, expect, it, vi } from 'vitest'; | |
| interface TestClockProps { | |
| target?: number | number[]; | |
| onChange: (value: Date) => void; | |
| } | |
| type UseClockSecond = (target?: number | number[]) => Date; | |
| const renderClock = ( | |
| useClockSecond: UseClockSecond, | |
| target: TestClockProps['target'], | |
| onChange: TestClockProps['onChange'] | |
| ) => { | |
| function TestClock({ target, onChange }: TestClockProps) { | |
| const value = useClockSecond(target); | |
| useEffect(() => { | |
| onChange(value); | |
| }, [value, onChange]); | |
| return null; | |
| } | |
| const container = document.createElement('div'); | |
| document.body.appendChild(container); | |
| const root = createRoot(container); | |
| act(() => { | |
| root.render(<TestClock target={target} onChange={onChange} />); | |
| }); | |
| return () => { | |
| act(() => { | |
| root.unmount(); | |
| }); | |
| container.remove(); | |
| }; | |
| }; | |
| afterEach(() => { | |
| vi.useRealTimers(); | |
| vi.resetModules(); | |
| }); | |
| describe('useClockSecond', () => { | |
| it('number 인자는 */n 조건에 맞을 때만 갱신된다', async () => { | |
| vi.useFakeTimers(); | |
| vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); | |
| const { useClockSecond } = await import('./use-clock'); | |
| const updates: number[] = []; | |
| const unmount = renderClock(useClockSecond, 2, (value) => | |
| updates.push(value.getSeconds()) | |
| ); | |
| await act(async () => { | |
| vi.advanceTimersByTime(1000); | |
| }); | |
| expect(updates).toEqual([0]); | |
| await act(async () => { | |
| vi.advanceTimersByTime(1000); | |
| }); | |
| expect(updates).toEqual([0, 2]); | |
| unmount(); | |
| }); | |
| it('number[] 인자는 목록에 해당하는 초에서만 갱신된다', async () => { | |
| vi.useFakeTimers(); | |
| vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); | |
| const { useClockSecond } = await import('./use-clock'); | |
| const updates: number[] = []; | |
| const unmount = renderClock(useClockSecond, [1, 3, 59.9, 61, -1], (value) => | |
| updates.push(value.getSeconds()) | |
| ); | |
| await act(async () => { | |
| vi.advanceTimersByTime(1000); | |
| }); | |
| expect(updates).toEqual([0, 1]); | |
| await act(async () => { | |
| vi.advanceTimersByTime(1000); | |
| }); | |
| expect(updates).toEqual([0, 1]); | |
| await act(async () => { | |
| vi.advanceTimersByTime(1000); | |
| }); | |
| expect(updates).toEqual([0, 1, 3]); | |
| unmount(); | |
| }); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useCallback, useRef, useSyncExternalStore } from 'react'; | |
| type ClockInterval = 's' | 'm' | 'h' | 'd'; | |
| type ClockTarget = number | number[]; | |
| type NormalizedClockTarget = | |
| | { type: 'every'; step: number } | |
| | { type: 'list'; values: Set<number> } | |
| | null; | |
| const normalizeClockTarget = (target?: ClockTarget): NormalizedClockTarget => { | |
| if (target === undefined) return null; | |
| if (Array.isArray(target)) { | |
| const values = target | |
| .map((value) => | |
| Number.isFinite(value) ? Math.floor(value) : (null as number | null) | |
| ) | |
| .filter((value): value is number => value !== null) | |
| .filter((value) => value >= 0 && value <= 60); | |
| return { type: 'list', values: new Set(values) }; | |
| } | |
| if (!Number.isFinite(target)) return null; | |
| const step = Math.floor(target); | |
| if (step <= 0) return null; | |
| return { type: 'every', step }; | |
| }; | |
| const shouldUpdateByTarget = (value: number, target: NormalizedClockTarget) => { | |
| if (!target) return true; | |
| if (target.type === 'every') return value % target.step === 0; | |
| return target.values.has(value); | |
| }; | |
| interface ClockStore { | |
| currentTime: Date; | |
| listeners: Set<() => void>; | |
| timeoutId: ReturnType<typeof setTimeout> | null; | |
| } | |
| const stores: Record<ClockInterval, ClockStore> = { | |
| s: { currentTime: new Date(), listeners: new Set(), timeoutId: null }, | |
| m: { currentTime: new Date(), listeners: new Set(), timeoutId: null }, | |
| h: { currentTime: new Date(), listeners: new Set(), timeoutId: null }, | |
| d: { currentTime: new Date(), listeners: new Set(), timeoutId: null }, | |
| }; | |
| // 정각 보정 | |
| stores.m.currentTime.setSeconds(0); | |
| stores.h.currentTime.setSeconds(0); | |
| stores.d.currentTime.setSeconds(0); | |
| stores.h.currentTime.setMinutes(0); | |
| stores.d.currentTime.setMinutes(0); | |
| stores.d.currentTime.setHours(0); | |
| const getNextDelay = (interval: ClockInterval, now: Date): number => { | |
| const ms = now.getTime(); | |
| switch (interval) { | |
| case 's': | |
| return 1000 - (ms % 1000); | |
| case 'm': | |
| return (60 - now.getSeconds()) * 1000 - (ms % 1000); | |
| case 'h': | |
| return ( | |
| (60 - now.getMinutes()) * 60 * 1000 - | |
| now.getSeconds() * 1000 - | |
| (ms % 1000) | |
| ); | |
| case 'd': { | |
| const tomorrow = new Date( | |
| now.getFullYear(), | |
| now.getMonth(), | |
| now.getDate() + 1 | |
| ); | |
| return tomorrow.getTime() - ms; | |
| } | |
| } | |
| }; | |
| const tick = (interval: ClockInterval) => { | |
| const store = stores[interval]; | |
| store.currentTime = new Date(); | |
| store.listeners.forEach((l) => l()); | |
| const delay = getNextDelay(interval, store.currentTime); | |
| store.timeoutId = setTimeout(() => tick(interval), delay); | |
| }; | |
| // 각 주기별로 '절대 변하지 않는' 함수 참조를 미리 생성 | |
| const createClockInfrastructures = (interval: ClockInterval) => { | |
| const store = stores[interval]; | |
| const subscribe = (onStoreChange: () => void) => { | |
| store.listeners.add(onStoreChange); | |
| if (store.listeners.size === 1 && !store.timeoutId) { | |
| const delay = getNextDelay(interval, new Date()); | |
| store.timeoutId = setTimeout(() => tick(interval), delay); | |
| } | |
| return () => { | |
| store.listeners.delete(onStoreChange); | |
| if (store.listeners.size === 0 && store.timeoutId) { | |
| clearTimeout(store.timeoutId); | |
| store.timeoutId = null; | |
| } | |
| }; | |
| }; | |
| const getSnapshot = () => store.currentTime; | |
| return { subscribe, getSnapshot }; | |
| }; | |
| const infra = { | |
| s: createClockInfrastructures('s'), | |
| m: createClockInfrastructures('m'), | |
| h: createClockInfrastructures('h'), | |
| d: createClockInfrastructures('d'), | |
| }; | |
| const createClockHook = ( | |
| interval: ClockInterval, | |
| getValue: (date: Date) => number | |
| ) => { | |
| return (target?: ClockTarget) => { | |
| const targetRef = useRef<NormalizedClockTarget>( | |
| normalizeClockTarget(target) | |
| ); | |
| targetRef.current = normalizeClockTarget(target); | |
| const lastMatchRef = useRef<Date>(infra[interval].getSnapshot()); | |
| const getSnapshot = useCallback(() => { | |
| const current = infra[interval].getSnapshot(); | |
| if (shouldUpdateByTarget(getValue(current), targetRef.current)) { | |
| lastMatchRef.current = current; | |
| } | |
| return lastMatchRef.current; | |
| }, []); | |
| return useSyncExternalStore( | |
| infra[interval].subscribe, | |
| getSnapshot, | |
| getSnapshot | |
| ); | |
| }; | |
| }; | |
| /** 초 단위 Date 객체 (매 초 갱신) */ | |
| export const useClockSecond = createClockHook('s', (date) => date.getSeconds()); | |
| /** 분 단위 Date 객체 (매 분 00초 갱신) */ | |
| export const useClockMinute = createClockHook('m', (date) => date.getMinutes()); | |
| /** 시 단위 Date 객체 (매 시 00분 00초 갱신) */ | |
| export const useClockHour = createClockHook('h', (date) => date.getHours()); | |
| /** 일 단위 Date 객체 (매일 00시 00분 00초 갱신) */ | |
| export function useClockDay() { | |
| return useSyncExternalStore( | |
| infra.d.subscribe, | |
| infra.d.getSnapshot, | |
| infra.d.getSnapshot | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment