Skip to content

Instantly share code, notes, and snippets.

@composite
Created January 20, 2026 01:25
Show Gist options
  • Select an option

  • Save composite/cba75ced96dc559493344650c79f0fd8 to your computer and use it in GitHub Desktop.

Select an option

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.
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();
});
});
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