HooksuseCalendar

useCalendar

선언적이고 νƒ€μž… μ•ˆμ „ν•œ 달λ ₯ Hookμž…λ‹ˆλ‹€. UIλŠ” μ™„μ „νžˆ 자유둭게 κ΅¬μ„±ν•˜κ³ , 데이터와 둜직만 μ œκ³΅λ°›μŠ΅λ‹ˆλ‹€.

μ™œ λ§Œλ“€μ—ˆλ‚˜μš”?

κΈ°μ‘΄ 달λ ₯ λΌμ΄λΈŒλŸ¬λ¦¬λ“€μ€ UIκ°€ κ°•μ œλ˜κ±°λ‚˜, date 계산 λ‘œμ§μ„ 직접 κ΅¬ν˜„ν•΄μ•Ό ν•˜λŠ” λΆˆνŽΈν•¨μ΄ μžˆμ—ˆμŠ΅λ‹ˆλ‹€.

// ❌ κΈ°μ‘΄ 방식 1: UIκ°€ κ°•μ œλ¨
<ReactCalendar />; // μŠ€νƒ€μΌ μ»€μŠ€ν„°λ§ˆμ΄μ§• 어렀움
 
// ❌ κΈ°μ‘΄ 방식 2: λ‘œμ§μ„ 직접 κ΅¬ν˜„
const [currentMonth, setCurrentMonth] = useState(new Date());
const daysInMonth = getDaysInMonth(currentMonth); // 직접 계산
const firstDayOfWeek = getDay(startOfMonth(currentMonth)); // 볡작...
// 이전달/λ‹€μŒλ‹¬ λ‚ μ§œλŠ”...? 😱
 
// βœ… useCalendar: λ°μ΄ν„°λ§Œ λ°›κ³ , UIλŠ” 자유둭게
const cal = useCalendar();
// cal.days β†’ 42개 λ‚ μ§œ (이전달+ν˜„μž¬λ‹¬+λ‹€μŒλ‹¬ 포함)
// cal.prev(), cal.next() β†’ λ„€λΉ„κ²Œμ΄μ…˜
// λ‚˜λ¨Έμ§€λŠ” λ‚΄ λ§ˆμŒλŒ€λ‘œ!

μ£Όμš” νŠΉμ§•

1. Headless UI (UI μ™„μ „ 자유)

μŠ€νƒ€μΌ, λ ˆμ΄μ•„μ›ƒ, μ• λ‹ˆλ©”μ΄μ…˜ λͺ¨λ‘ 직접 μ œμ–΄ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

2. νƒ€μž… μ•ˆμ „μ„±

TypeScript둜 μž‘μ„±λ˜μ–΄ IDE μžλ™μ™„μ„±κ³Ό νƒ€μž… 체크λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€.

3. SSR μ•ˆμ „

Next.js, Remix λ“± SSR ν™˜κ²½μ—μ„œλ„ hydration μ—λŸ¬ 없이 λ™μž‘ν•©λ‹ˆλ‹€.

4. μ„±λŠ₯ μ΅œμ ν™”

λΆˆν•„μš”ν•œ μž¬κ³„μ‚°μ„ λ°©μ§€ν•˜κ³ , μ•ˆμ •μ μΈ μ°Έμ‘°λ₯Ό μœ μ§€ν•©λ‹ˆλ‹€.


μ„€μΉ˜

npm install @frontend-toolkit-js/hooks
# or
pnpm add @frontend-toolkit-js/hooks

κΈ°λ³Έ μ‚¬μš©λ²•

1. κ°€μž₯ κ°„λ‹¨ν•œ 예제

import { useCalendar } from '@frontend-toolkit-js/hooks';
 
function MyCalendar() {
  const cal = useCalendar();
 
  return (
    <div>
      {/* 헀더: 이전/λ‹€μŒ λ²„νŠΌ */}
      <div>
        <button onClick={cal.prev}>β—€ 이전</button>
        <h2>
          {cal.currentDate.toLocaleDateString('ko-KR', {
            year: 'numeric',
            month: 'long',
          })}
        </h2>
        <button onClick={cal.next}>λ‹€μŒ β–Ά</button>
      </div>
 
      <button onClick={cal.today}>였늘</button>
 
      {/* μš”μΌ 헀더 */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
        {cal.weekdays.map(weekday => (
          <div key={weekday.index}>{weekday.label}</div>
        ))}
      </div>
 
      {/* λ‚ μ§œ κ·Έλ¦¬λ“œ */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
        {cal.days.map((day, i) => (
          <div
            key={i}
            style={{
              opacity: day.isCurrentMonth ? 1 : 0.3,
              fontWeight: day.isToday ? 'bold' : 'normal',
              color: day.isWeekend ? 'red' : 'black',
            }}
          >
            {day.day}
          </div>
        ))}
      </div>
    </div>
  );
}

API Reference

Options

interface UseCalendarOptions {
  defaultDate?: Date | string | number;
  weekStartsOn?: 0 | 1;
  onChange?: (date: Date) => void;
}

defaultDate

초기 ν‘œμ‹œν•  λ‚ μ§œμž…λ‹ˆλ‹€. λ‹€μ–‘ν•œ ν˜•μ‹μ„ μ§€μ›ν•©λ‹ˆλ‹€.

// Date 객체
useCalendar({ defaultDate: new Date(2024, 0, 15) });
 
// ISO λ¬Έμžμ—΄
useCalendar({ defaultDate: '2024-01-15' });
 
// λ…„-μ›”λ§Œ (일은 μžλ™μœΌλ‘œ 1일)
useCalendar({ defaultDate: '2024-01' });
 
// timestamp
useCalendar({ defaultDate: 1705276800000 });
 
// URLμ—μ„œ κ°€μ Έμ˜€κΈ°
const params = new URLSearchParams(window.location.search);
useCalendar({ defaultDate: params.get('date') });
  • νƒ€μž…: Date | string | number
  • κΈ°λ³Έκ°’: new Date() (였늘)

weekStartsOn

주의 μ‹œμž‘ μš”μΌμ„ μ„€μ •ν•©λ‹ˆλ‹€.

// μΌμš”μΌ μ‹œμž‘ (ν•œκ΅­ 달λ ₯)
useCalendar({ weekStartsOn: 0 });
 
// μ›”μš”μΌ μ‹œμž‘ (ISO 8601 ν‘œμ€€)
useCalendar({ weekStartsOn: 1 });
  • νƒ€μž…: 0 | 1
  • κΈ°λ³Έκ°’: 0 (μΌμš”μΌ)

onChange

λ‚ μ§œκ°€ 변경될 λ•Œ ν˜ΈμΆœλ˜λŠ” μ½œλ°±μž…λ‹ˆλ‹€.

useCalendar({
  onChange: date => {
    console.log('λ‚ μ§œ λ³€κ²½:', date);
 
    // URL μ—…λ°μ΄νŠΈ
    const params = new URLSearchParams();
    params.set('date', format(date, 'yyyy-MM'));
    window.history.pushState({}, '', `?${params}`);
  },
});
  • νƒ€μž…: (date: Date) => void
  • κΈ°λ³Έκ°’: undefined

λ°˜ν™˜κ°’

{
  days: CalendarDay[];
  weekdays: Weekday[];
  currentDate: Date;
  prev: () => void;
  next: () => void;
  today: () => void;
  setDate: (date: Date) => void;
}

days: CalendarDay[]

달λ ₯에 ν‘œμ‹œν•  42개의 λ‚ μ§œ λ°°μ—΄μž…λ‹ˆλ‹€. (6μ£Ό Γ— 7일)

interface CalendarDay {
  date: Date; // 전체 Date 객체
  day: number; // 일 (1-31)
  isCurrentMonth: boolean; // ν˜„μž¬ ν‘œμ‹œ 월에 μ†ν•˜λŠ”κ°€?
  isToday: boolean; // μ˜€λŠ˜μΈκ°€?
  isWeekend: boolean; // 주말인가? (ν† /일)
}

μ™œ 42개?

  • λŒ€λΆ€λΆ„μ˜ 달은 35개(5μ£Ό)둜 μΆ©λΆ„ν•˜μ§€λ§Œ, 6μ£Όκ°€ ν•„μš”ν•œ κ²½μš°λ„ 있음
  • κ³ μ • 크기둜 λ ˆμ΄μ•„μ›ƒ μ•ˆμ •μ„± 확보
  • 이전 달/λ‹€μŒ 달 λ‚ μ§œλ„ ν¬ν•¨ν•˜μ—¬ μ™„μ „ν•œ μ£Ό λ‹¨μœ„ ν‘œμ‹œ
// 이전 달 λ‚ μ§œ 흐리게
{
  cal.days.map(day => (
    <div style={{ opacity: day.isCurrentMonth ? 1 : 0.3 }}>{day.day}</div>
  ));
}

weekdays: Weekday[]

μš”μΌ 헀더λ₯Ό μœ„ν•œ 7개의 μš”μΌ λ°°μ—΄μž…λ‹ˆλ‹€.

interface Weekday {
  date: Date; // Date 객체
  label: string; // 짧은 λ ˆμ΄λΈ” ("μ›”", "ν™”", ...)
  labelLong: string; // κΈ΄ λ ˆμ΄λΈ” ("μ›”μš”μΌ", "ν™”μš”μΌ", ...)
  index: number; // μš”μΌ 인덱슀 (0-6)
}
// μš”μΌ 헀더
{
  cal.weekdays.map(weekday => (
    <div key={weekday.index}>
      {weekday.label} {/* "μ›”", "ν™”", ... */}
    </div>
  ));
}

currentDate: Date

ν˜„μž¬ ν‘œμ‹œ 쀑인 λ‹¬μ˜ Date κ°μ²΄μž…λ‹ˆλ‹€.

<h2>
  {cal.currentDate.toLocaleDateString('ko-KR', {
    year: 'numeric',
    month: 'long',
  })}
</h2>
// β†’ "2024λ…„ 1μ›”"

prev(): void

이전 λ‹¬λ‘œ μ΄λ™ν•©λ‹ˆλ‹€.

<button onClick={cal.prev}>β—€ 이전 달</button>

next(): void

λ‹€μŒ λ‹¬λ‘œ μ΄λ™ν•©λ‹ˆλ‹€.

<button onClick={cal.next}>λ‹€μŒ 달 β–Ά</button>

today(): void

였늘이 μ†ν•œ λ‹¬λ‘œ μ΄λ™ν•©λ‹ˆλ‹€.

<button onClick={cal.today}>였늘</button>

setDate(date: Date): void

νŠΉμ • λ‚ μ§œλ‘œ 직접 μ„€μ •ν•©λ‹ˆλ‹€.

<button onClick={() => cal.setDate(new Date(2024, 11, 25))}>
  크리슀마슀둜 이동
</button>

싀무 예제

1. λ‚ μ§œ 선택 κΈ°λŠ₯

import { useState } from 'react';
import { useCalendar } from '@frontend-toolkit-js/hooks';
import { isSameDay } from 'date-fns';
 
function DatePicker() {
  const cal = useCalendar();
  const [selected, setSelected] = useState<Date | null>(null);
 
  return (
    <div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
        {cal.days.map((day, i) => {
          const isSelected = selected && isSameDay(selected, day.date);
 
          return (
            <button
              key={i}
              onClick={() => setSelected(day.date)}
              style={{
                opacity: day.isCurrentMonth ? 1 : 0.3,
                fontWeight: day.isToday ? 'bold' : 'normal',
                backgroundColor: isSelected ? 'blue' : 'transparent',
                color: isSelected ? 'white' : day.isWeekend ? 'red' : 'black',
              }}
            >
              {day.day}
            </button>
          );
        })}
      </div>
 
      {selected && <p>μ„ νƒλœ λ‚ μ§œ: {selected.toLocaleDateString('ko-KR')}</p>}
    </div>
  );
}

2. URL 동기화 (Next.js App Router)

'use client';
 
import { useSearchParams, useRouter } from 'next/navigation';
import { useCalendar } from '@frontend-toolkit-js/hooks';
import { format } from 'date-fns';
 
function CalendarWithURL() {
  const searchParams = useSearchParams();
  const router = useRouter();
 
  const cal = useCalendar({
    defaultDate: searchParams.get('date') ?? undefined,
    onChange: date => {
      const params = new URLSearchParams(searchParams);
      params.set('date', format(date, 'yyyy-MM'));
      router.push(`?${params.toString()}`);
    },
  });
 
  return (
    <div>
      <p>ν˜„μž¬ URL: ?date={searchParams.get('date')}</p>
      {/* 달λ ₯ UI */}
    </div>
  );
}

λ™μž‘:

  • λ„€λΉ„κ²Œμ΄μ…˜ 클릭 β†’ URL μžλ™ μ—…λ°μ΄νŠΈ
  • URL 직접 λ³€κ²½ β†’ 달λ ₯ μžλ™ 반영
  • λΈŒλΌμš°μ € λ’€λ‘œκ°€κΈ° β†’ 이전 달 볡원

3. λ²”μœ„ 선택 (체크인/체크아웃)

import { useState } from 'react';
import { useCalendar } from '@frontend-toolkit-js/hooks';
import { isWithinInterval, isSameDay } from 'date-fns';
 
function DateRangePicker() {
  const cal = useCalendar();
  const [startDate, setStartDate] = useState<Date | null>(null);
  const [endDate, setEndDate] = useState<Date | null>(null);
 
  const handleClick = (date: Date) => {
    if (!startDate || (startDate && endDate)) {
      // μƒˆλ‘œ μ‹œμž‘
      setStartDate(date);
      setEndDate(null);
    } else {
      // μ’…λ£ŒμΌ μ„€μ •
      if (date < startDate) {
        setEndDate(startDate);
        setStartDate(date);
      } else {
        setEndDate(date);
      }
    }
  };
 
  return (
    <div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
        {cal.days.map((day, i) => {
          const isStart = startDate && isSameDay(startDate, day.date);
          const isEnd = endDate && isSameDay(endDate, day.date);
          const isInRange =
            startDate &&
            endDate &&
            isWithinInterval(day.date, { start: startDate, end: endDate });
 
          return (
            <button
              key={i}
              onClick={() => handleClick(day.date)}
              style={{
                backgroundColor:
                  isStart || isEnd ? 'blue' : isInRange ? 'lightblue' : 'white',
              }}
            >
              {day.day}
            </button>
          );
        })}
      </div>
    </div>
  );
}

4. 이벀트 ν‘œμ‹œ

import { format } from 'date-fns';
 
function EventCalendar() {
  const cal = useCalendar();
 
  const events = {
    '2024-01-15': ['회의 1', '회의 2'],
    '2024-01-20': ['생일 νŒŒν‹°'],
  };
 
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
      {cal.days.map((day, i) => {
        const dateKey = format(day.date, 'yyyy-MM-dd');
        const dayEvents = events[dateKey] || [];
 
        return (
          <div key={i}>
            <div>{day.day}</div>
            {dayEvents.map((event, j) => (
              <div key={j} style={{ fontSize: '10px', color: 'blue' }}>
                β€’ {event}
              </div>
            ))}
          </div>
        );
      })}
    </div>
  );
}

κ³ κΈ‰ μ‚¬μš©λ²•

μ›”μš”μΌ μ‹œμž‘ (ISO 8601)

const cal = useCalendar({ weekStartsOn: 1 });
 
// μš”μΌ 헀더: μ›” ν™” 수 λͺ© 금 ν†  일

λ‹€κ΅­μ–΄ 지원

import { ko, enUS } from 'date-fns/locale';
 
// ν•œκ΅­μ–΄
{
  cal.weekdays.map(w => <div>{format(w.date, 'EEE', { locale: ko })}</div>);
}
// β†’ μ›”, ν™”, 수, ...
 
// μ˜μ–΄
{
  cal.weekdays.map(w => <div>{format(w.date, 'EEE', { locale: enUS })}</div>);
}
// β†’ Mon, Tue, Wed, ...

λ‚΄λΆ€ λ™μž‘ 원리

μ™œ 42개 λ‚ μ§œλ₯Ό μƒμ„±ν•˜λ‚˜μš”?

  • 5μ£Όλ§ŒμœΌλ‘œλŠ” λΆ€μ‘±ν•œ κ²½μš°κ°€ 있음 (예: 2025λ…„ 3μ›”)
  • κ³ μ • 크기 β†’ λ ˆμ΄μ•„μ›ƒ shift μ—†μŒ
  • 이전/λ‹€μŒ 달 λ‚ μ§œ 포함 β†’ μ™„μ „ν•œ μ£Ό λ‹¨μœ„ ν‘œμ‹œ

μ™œ Flat Array κ΅¬μ‘°μΈκ°€μš”?

// ❌ 2D λ°°μ—΄ (μ£Ό λ‹¨μœ„)
const weeks = [
  [day1, day2, ...],  // 1μ£Ό
  [day8, day9, ...],  // 2μ£Ό
  // ...
];
 
// βœ… Flat λ°°μ—΄ (1차원)
const days = [day1, day2, ..., day42];

이유:

  • CSS Grid둜 λ ˆμ΄μ•„μ›ƒν•˜κΈ° νŽΈν•¨ (gridTemplateColumns: 'repeat(7, 1fr)')
  • .map() ν•œ 번으둜 λ Œλ”λ§ κ°€λŠ₯
  • λ©”λͺ¨λ¦¬ 효율적 (쀑첩 λ°°μ—΄ μ—†μŒ)

μ„±λŠ₯ νŠΉμ„±

λ²ˆλ“€ 크기

@frontend-toolkit-js/hooks 자체:

  • ESM: 2.6 KB (minified) / 1.06 KB (gzipped)
  • CJS: 2.8 KB (minified) / 1.05 KB (gzipped)

Dependencies:

  • date-fns: ~78 KB (ν•„μš”ν•œ ν•¨μˆ˜λ§Œ tree-shaking)
  • react: peer dependency (별도 μ„€μΉ˜ ν•„μš”)

πŸ’‘ μ°Έκ³ : date-fnsλŠ” tree-shaking을 μ§€μ›ν•˜λ―€λ‘œ, μ‹€μ œ μ‚¬μš©ν•˜λŠ” ν•¨μˆ˜λ§Œ λ²ˆλ“€μ— ν¬ν•¨λ©λ‹ˆλ‹€. useCalendarλŠ” μ•½ 10개의 date-fns ν•¨μˆ˜λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€.

λ Œλ”λ§ μ΅œμ ν™”

  • useMemo둜 λΆˆν•„μš”ν•œ μž¬κ³„μ‚° λ°©μ§€
  • useCallback으둜 μ•ˆμ •μ μΈ ν•¨μˆ˜ μ°Έμ‘°
  • λ‚ μ§œ λ³€κ²½ μ‹œμ—λ§Œ μž¬κ³„μ‚°

λ©”λͺ¨λ¦¬

  • useState 3개만 μ‚¬μš©
  • 타이머 μ—†μŒ (λ©”λͺ¨λ¦¬ λˆ„μˆ˜ μ—†μŒ)

μ£Όμ˜μ‚¬ν•­

❌ ν”ν•œ μ‹€μˆ˜

1. isCurrentMonth 체크 μ•ˆ 함

// ❌ 이전/λ‹€μŒ 달 λ‚ μ§œλ„ μ§„ν•˜κ²Œ ν‘œμ‹œλ¨
{
  cal.days.map(day => <div>{day.day}</div>);
}
 
// βœ… 이전/λ‹€μŒ 달은 흐리게
{
  cal.days.map(day => (
    <div style={{ opacity: day.isCurrentMonth ? 1 : 0.3 }}>{day.day}</div>
  ));
}

2. key에 index만 μ‚¬μš©

// ⚠️ λ™μž‘μ€ ν•˜μ§€λ§Œ ꢌμž₯ν•˜μ§€ μ•ŠμŒ
{
  cal.days.map((day, i) => <div key={i}>{day.day}</div>);
}
 
// βœ… 더 μ•ˆμ •μ 
{
  cal.days.map((day, i) => (
    <div key={`${day.date.getTime()}-${i}`}>{day.day}</div>
  ));
}

3. onChangeμ—μ„œ λ¬΄ν•œ 루프

// ❌ λ¬΄ν•œ 루프!
const [date, setDate] = useState(new Date());
 
useCalendar({
  onChange: newDate => {
    setDate(newDate);
    cal.setDate(newDate); // ← 이러면 μ•ˆ 됨!
  },
});
 
// βœ… onChangeλŠ” side effect만
useCalendar({
  onChange: newDate => {
    // URL μ—…λ°μ΄νŠΈ, λ‘œκΉ… λ“±λ§Œ
    updateURL(newDate);
  },
});

TypeScript

λͺ¨λ“  νƒ€μž…μ΄ μžλ™μœΌλ‘œ μΆ”λ‘ λ©λ‹ˆλ‹€.

const cal = useCalendar();
//    ^? {
//      days: CalendarDay[];
//      weekdays: Weekday[];
//      currentDate: Date;
//      ...
//    }
 
cal.days.forEach(day => {
  day.isToday;
  //  ^? boolean (μžλ™ μΆ”λ‘ )
});

λΈŒλΌμš°μ € 지원

  • Chrome, Firefox, Safari, Edge (μ΅œμ‹  2λ…„)
  • React 18+
  • date-fns 4.x

κ΄€λ ¨ λ¬Έμ„œ


참고 자료