프로젝트/하루한냥

[하루한냥] 캘린더 구현 : Zustand를 이용한 상태관리 구현하기

알파카털파카 2023. 8. 11. 17:05
[하루한냥]
캘린더 구현 : Zustand를 이용한 상태관리 구현하기

 

 

이전 시간에 만든 캘린더 컴포넌트에 Zustand를 이용해 상태관리 store를 구현했다. 헤더에서 날짜가 바뀌면 캘린더 컴포넌트의 달력이 바뀌어야 한다. useDateStore를 이용해 두 컴포넌트를 연결하고 상태값을 관리하도록 했다. zustand는 사용법이 간단하기 때문에 처음 사용해보는 라이브러리였지만 크게 어려움을 겪지는 않았다.

 

 


 

 

1. Zustand Store 생성하기

달력의 날짜를 관리할 스토어이기 때문에 useDateStore라고 이름을 붙였다. 스토어에는 상태와 액션을 저장한다. 상태에는 유저가 애플리케이션을 이용하는 순간의 현재 날짜인 currentDate, 헤더의 화살표를 이용해 이동할 대상 날짜인 targetDate 두 가지를 정의했다. targetDate는 날짜 이동을 하지 않은 초기 상태에서는 null값을 주었다. 액션에는 달의 첫날을 구하는 getFirstDayOfMonth와, targetDate를 변화시키는 setTargetDate를 만들었다.

 

setTargetDate에서 month에 -1을 해 준 이유는 다른 곳에서 month값을 사용할 때 0부터 시작하는 month의 반환값에 +1을 해서 사용했기 때문에, 다시 자바스크립트가 이해할 수 있도록 마이너스 처리한 것이다. 캘린더를 구현하면서 이 month값 때문에 골머리를 앓았다.

 

// src/lib/store/useDateStore.ts

import { create } from 'zustand';

type State = {
  currentDate: Date;
  targetDate: Date | null;
};

type Action = {
  getFirstDayOfMonth: (date: Date) => Date;
  setTargetDate: (year: number, month: number) => void;
};

type DateStore = State & Action;

const INITIAL_DATE = new Date();

const useDateStore = create<DateStore>((set) => ({
  currentDate: INITIAL_DATE,
  targetDate: null,
  getFirstDayOfMonth: (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1),
  setTargetDate: (year: number, month: number) =>
    set({
      targetDate: new Date(year, month - 1),
    }),
}));

export default useDateStore;

 

 

 

 

2. Store 이용하기 

만들어놓은 스토어를 컴포넌트에서 활용하는 단계이다. 캘린더 페이지 컴포넌트와 헤더(빨간색)에서 스토어 값을 바인딩할 것이다. store는 hook이 되며, 컴포넌트에서 import해 사용하면 된다. provider가 없어도 사용할 수 있으며, 상태를 선택하면 해당 상태가 변경될 때 컴포넌트가 다시 렌더링된다. 

 

CalendarPage 컴포넌트

 

import useDateStore from '@lib/store/useDateStore';

const [currentDate, targetDate, setTargetDate] = useDateStore((state) => [
  state.currentDate,
  state.targetDate,
  state.setTargetDate,
]);

 

 

 

 

3. Header 컴포넌트와 Store 바인딩

컴포넌트 전반에서 스토어 값이 사용되므로, 부분별로 나누어서 살펴보려 한다. 헤더의 주 기능은 '날짜 변경하기' 이다. 스토어를 이용해 날짜를 어떻게 변경시키고, UX 측면에서 개선하려면 어떻게 해야할 지 살펴보자.

 

 

3-1. 날짜 변경 로직 구현하기

화살표를 누르면 달이 변경된다. 날짜값이 YYYY년 M월  형태로 표기된다. 포인트는 현재 날짜보다 미래로 가지는 못한다는 점이다. 화살표는 svg 이미지를 사용했고, svg 파일의 fill값을 동적으로 변경해주는 것이 어려워서 왼쪽 화살표, 오른쪽 화살표, 오른쪽 화살표 비활성 버전 3가지를 준비했다. 

 

헤더

 

return (
  <Container>
    <Arrow onClick={handleChangeDateToPrev}>
      <img src="images/icon/arrow-left-active.svg" alt="arrow-left-active" />
    </Arrow>
    <SelectDate>{calendarTargetDateString}</SelectDate>
    <Arrow onClick={handleChangeDateToNext}>
      <>
        {isActiveNext ? (
          <img src="images/icon/arrow-right-active.svg" alt="arrow-right-active" />
        ) : (
          <img src="images/icon/arrow-right-disabled.svg" alt="arrow-right-disabled" />
        )}
      </>
    </Arrow>
  </Container>
);

 

스토어에서는 현재날짜, 타겟날짜, 타겟날짜를 변경하는 함수를 가져왔다. targetDate의 초기값이 null이기 때문에, 이 부분을 신경쓰지 않으면  오류가 발생한다. targetDate !== null 등의 로직을 활용한다. 이렇게하면 날짜를 이동하는 로직이 완성된다. 

 

// Store 가져오기
const [currentDate, targetDate, setTargetDate] = useDateStore((state) => [
  state.currentDate,
  state.targetDate,
  state.setTargetDate,
]);

// 날짜 이동
const handleChangeTargetDate = (type: 'prev' | 'next') => {
  if (targetDate !== null) {
    const mappedTypeNumber = type === 'prev' ? -1 : +1;
    targetDate.setMonth(targetDate.getMonth() + mappedTypeNumber);
    const year = targetDate.getFullYear();
    const month = targetDate.getMonth() + 1;
    navigate(`/calendar?year=${year}&month=${month}`);
  }
};

// 과거로 이동
const handleChangeDateToPrev = () => {
  handleChangeTargetDate('prev');
};

// 현재보다 미래인지 판별
const isActiveNext = useMemo(() => {
  if (targetDate === null) {
    return false;
  }
  const targetMonth = `${targetDate.getFullYear()}${targetDate.getMonth()}`;
  const currentMonth = `${currentDate.getFullYear()}${currentDate.getMonth()}`;
  return targetMonth < currentMonth;
}, [currentDate, targetDate]);

// 미래로 이동
const handleChangeDateToNext = () => {
  if (isActiveNext) {
    handleChangeTargetDate('next');
  }
};

// 날짜 표기
const calendarTargetDateString =
  targetDate !== null
    ? `${targetDate.getFullYear()}년 ${targetDate.getMonth() + 1}월`
    : `${currentDate.getFullYear()}년 ${currentDate.getMonth() + 1}월`;

 

 

3-2. 쿼리 스트링을 이용해 새로고침해도 값이 유지되도록 구현하기

유저가 캘린더를 이용하다가 2023년 5월로 날짜를 이동하고, 새로고침을 하면 현재 날짜로 돌아간다. 유저가 다시 처음부터 뒤로 날짜를 이동해야 하는 불편함을 겪어야 하기 때문에, 이를 방지하기 위해 쿼리 스트링을 이용하기로 했다. 아래 코드는 해당 부분의 로직만 모아둔 코드이다.

 

우선, handleChangeTargetDate 함수에서 TargeDate를 이용해 새로운 Date 객체를 생성한다. 날짜 이동 화살표가 ⬅️, ➡️ 두 가지가 있기 때문에 각각 prev와 next로 타입을 넘겨주고, 이를 반영해 달을 더할 것인지 뺄 것인지 계산한다. 이렇게 계산된 타겟 날짜를 가지고 url에 쿼리 스트링으로 넣어 라우팅 처리를 한다.

 

두번째로, 이렇게 변경된 날짜로 이동된 url 주소에서 파라미터를 가져온다. React Router의 useSearchParams를 이용하면 현재 위치에 대한 쿼리 스트링을 가져올 수 있다. 이렇게 가져온 값은 string 형태이므로, parseInt를 사용해 10진수의 number값으로 변환시켜야 한다.

 

마지막으로, 변경된 url 주소에서 추출한 값을 스토어의 setTatgetDate 함수에 넣어 TargeDate를 변경시킨다. 이렇게 하면 새로고침해도 날짜가 현재값으로 돌아가지 않는다. 

 

const navigate = useNavigate();

// 2️⃣ 파라미터 가져오기
const [searchParams] = useSearchParams();

const paramYear = searchParams.get('year');
const paramMonth = searchParams.get('month');

// 3️⃣ 숫자로 변경
const parseYear = paramYear ? parseInt(paramYear, 10) : null;
const parseMonth = paramMonth ? parseInt(paramMonth, 10) : null;

// 1️⃣ 날짜에 따른 url 변경
const handleChangeTargetDate = (type: 'prev' | 'next') => {
    if (targetDate !== null) {
    const mappedTypeNumber = type === 'prev' ? -1 : +1;
    targetDate.setMonth(targetDate.getMonth() + mappedTypeNumber);
    const year = targetDate.getFullYear();
    const month = targetDate.getMonth() + 1;
    navigate(`/calendar?year=${year}&month=${month}`);
  }
};

// 4️⃣ targetDate 변경하기
useEffect(() => {
  const hasQueryString = parseYear && parseMonth;
  if (hasQueryString) {
    setTargetDate(parseYear, parseMonth);
  }
}, [parseYear, parseMonth, setTargetDate]);

 

완성된 Header 컴포넌트의 코드는 아래와 같다.

 

import styled from '@emotion/styled';
import { useEffect, useMemo } from 'react';
import useDateStore from '@lib/store/useDateStore';
import { useNavigate } from 'react-router';
import { useSearchParams } from 'react-router-dom';
import styleToken from '../../styles/styleToken.css';

export default function Header() {
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();

  const paramYear = searchParams.get('year');
  const paramMonth = searchParams.get('month');

  const parseYear = paramYear ? parseInt(paramYear, 10) : null;
  const parseMonth = paramMonth ? parseInt(paramMonth, 10) : null;

  const [currentDate, targetDate, setTargetDate] = useDateStore((state) => [
    state.currentDate,
    state.targetDate,
    state.setTargetDate,
  ]);

  const handleChangeTargetDate = (type: 'prev' | 'next') => {
    if (targetDate !== null) {
      const mappedTypeNumber = type === 'prev' ? -1 : +1;
      targetDate.setMonth(targetDate.getMonth() + mappedTypeNumber);
      const year = targetDate.getFullYear();
      const month = targetDate.getMonth() + 1;
      navigate(`/calendar?year=${year}&month=${month}`);
    }
  };

  const handleChangeDateToPrev = () => {
    handleChangeTargetDate('prev');
  };

  const isActiveNext = useMemo(() => {
    if (targetDate === null) {
      return false;
    }
    const targetMonth = `${targetDate.getFullYear()}${targetDate.getMonth()}`;
    const currentMonth = `${currentDate.getFullYear()}${currentDate.getMonth()}`;
    return targetMonth < currentMonth;
  }, [currentDate, targetDate]);

  const handleChangeDateToNext = () => {
    if (isActiveNext) {
      handleChangeTargetDate('next');
    }
  };

  const calendarTargetDateString =
    targetDate !== null
      ? `${targetDate.getFullYear()}년 ${targetDate.getMonth() + 1}월`
      : `${currentDate.getFullYear()}년 ${currentDate.getMonth() + 1}월`;

  useEffect(() => {
    const hasQueryString = parseYear && parseMonth;
    if (hasQueryString) {
      setTargetDate(parseYear, parseMonth);
    }
  }, [parseYear, parseMonth, setTargetDate]);

  return (
    <Container>
      <Arrow onClick={handleChangeDateToPrev}>
        <img src="images/icon/arrow-left-active.svg" alt="arrow-left-active" />
      </Arrow>
      <SelectDate>{calendarTargetDateString}</SelectDate>
      <Arrow onClick={handleChangeDateToNext}>
        <>
          {isActiveNext ? (
            <img src="images/icon/arrow-right-active.svg" alt="arrow-right-active" />
          ) : (
            <img src="images/icon/arrow-right-disabled.svg" alt="arrow-right-disabled" />
          )}
        </>
      </Arrow>
    </Container>
  );
}

 

 

 

 

4. CalendarPage 컴포넌트와 Store 바인딩

헤더에서 날짜를 변경하면, 이 값이 캘린더 페이지에서도 영향을 미쳐야 한다. 그래서 이동한 날짜에 해당하는 달력을 보여줘야 한다. 스토어에서 현재날짜, 타겟날짜, 타겟날짜를 변경하는 함수, 달의 첫날을 반환하는 함수를 가져왔다. 역시 targetDate가 null일 때의 처리를 해야 한다. 이전 글에서 작성한 코드를 바탕으로 스토어를 적용한다.  

 

export default function CalendarPage() {  
  // Store 가져오기
  const [currentDate, targetDate, setTargetDate, getFirstDayOfMonth] = useDateStore((state) => [
    state.currentDate,
    state.targetDate,
    state.setTargetDate,
    state.getFirstDayOfMonth,
  ]);
  
  // 해당 달이 며칠까지 있는지 계산
  const getTargetMonthLastDay = () => {
    if (targetDate !== null) {
      const lastDateInTargetMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0);
      return lastDateInTargetMonth.getDate();
    }
    const lastDateInTargetMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0);
    return lastDateInTargetMonth.getDate();
  };

  const daysInMonth = getTargetMonthLastDay();

  // 해당 달이 무슨 요일부터 시작하는지 계산
  const firstDayOfMonth = targetDate !== null ? getFirstDayOfMonth(targetDate).getDay() : 0;

  // 오늘 날짜 계산
  const isTodayWithoutDate = () => {
    if (targetDate !== null) {
      const year = targetDate.getFullYear() === currentDate.getFullYear();
      const month = targetDate.getMonth() === currentDate.getMonth();
      return year && month;
    }
    return false;
  };

  // 현재 날짜보다 미래인지 계산
  const isDisabledWithoutDate = () => {
    if (targetDate !== null) {
      const year = targetDate.getFullYear() === currentDate.getFullYear();
      const month = targetDate.getMonth() === currentDate.getMonth();
      return year && month;
    }
    return false;
  };

 

isTodayWithoutDate와 isDisabledWithoutDate 함수는 각각 return되는 컴포넌트 내부에서 날짜를 어떻게 표시해줄지 계산하는 함수를 분리해둔 것이다. DateColumn 컴포넌트는 날짜와 타입에 따라 다르게 표시된다.

 

  return (
    <>
      <Header />
      <Container>
        <WeekRow>
          <>
            {dayName.map((day) => (
              <span key={day}>{day}</span>
            ))}
          </>
        </WeekRow>
        <WeekRow>
          <>
            {range(firstDayOfMonth).map((day) => (
              <div key={day} />
            ))}
            {range(daysInMonth, 1).map((date) => {
              const findElement = monthlyDiary.find((el) => el.createDate.date === date);
              const isToday = isTodayWithoutDate() && date === currentDate.getDate();
              const isDisabled = isDisabledWithoutDate() && date > currentDate.getDate();
              if (findElement) {
                return (
                  <DateColumn key={date} date={date} type={findElement.feel} />
                );
              }
              if (isToday) {
                return (
                  <DateColumn key={date} date={date} type="today" />
                );
              }
              if (isDisabled) {
                return (
                  <DateColumn key={date} date={date} type="disabled" />
                );
              }
              return (
                <DateColumn key={date} date={date} type="available" />
              );
            })}
          </>
        </WeekRow>
      </Container>
      <Menu />
    </>
  );
}

 

날짜와 타입에 따라 다르게 표시되는 캘린더

 

 

 

 

마치며

테스트의 위대함을 느끼고 있다. 각종 타입 오류나 허술한 로직을 보완하면서 작성하려고 노력 중이다. 이번에 구현한 기능 중에서 특히 타입별로 날짜 컴포넌트가 다르게 나오는 것이 재밌었다. 0~11로 반환되는 month값이 날 힘들게 했는데, 달에 +1과 -1 처리를 하는 부분이 의외로 복잡했다. 이론은 간단한데 막상 사용하려니 머리가 꼬였다. 스토어를 활용하는 부분은 리덕스나 다른 상태관리 라이브러리보다 쉬웠고, 즐겁게 개발할 수 있었다.