🐕 easy to start
https://tailwindcss.com/docs/dark-mode#toggling-dark-mode-manually
tailwind 공식 문서를 참고하면서 작성합니다.
로컬스토리지를 활용한 state 작성 및 기본적인 활용을 실습해보겠습니다.
공식문서 쪽의 설명이 더 좋으신 분들은 그 쪽을 참고해주시면 될 것 같습니다.
본 포스트는 타입스크립트 , 리액트를 동시에 사용하는 환경을 초점으로 작성되었습니다.
하지만 실제로 타입을 많이 사용하진 않으니 그대로 js 환경에 적용해도 크게 무리는 없습니다..
👻dark 모드를 사용하기 위한 설정
tailwind.config.js에 들어가 darkMode:"class"를 작성해줍니다.
tailwind.config.js가 없으신분들은..
https://xionwcfm.tistory.com/277
이 포스트를 참고하면서 테일윈드 환경을 구성해주세요
By default this uses the prefers-color-scheme CSS media feature, but you can also build sites that support toggling dark mode manually using the ‘class’ strategy.
darkMode: 'class'가 의미하는 바는
기본적으로는 media 속성을 사용하지만 darkMode를 class로 설정해주면
class를 add,remove하는 것을 통해 다크모드 여부를 컨트롤할 수 있게해준다라고 볼 수 있습니다.
그러면 이제 작성만 해주면 되겠네요!
🥶 실제로 로직 작성하기 with localstorage
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
// Whenever the user explicitly chooses light mode
localStorage.theme = 'light'
// Whenever the user explicitly chooses dark mode
localStorage.theme = 'dark'
// Whenever the user explicitly chooses to respect the OS preference
localStorage.removeItem('theme')
공식문서에서 안내해주는 설정은 다음과 같습니다.
살짝 생소한 코드들이 보일 수 있는데 전혀 어렵지 않습니다.
localStorage란 브라우저의 localStorage에 접근하는 것을 뜻합니다.
로컬스토리지는 키밸류형태로 값을 저장해줄 수 있는 일종의 자료구조라고 생각해주세요!
window.matchMedia는 전역객체 winodw가 제공해주는 web api의 일종입니다.
https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia
window.matchMedia('(prefers-color-scheme: dark)')
간단하게 요약하면 사용자의 기본 시스템 설정에 접근하는데 prefers-color-scheme에 접근하는 것입니다.
이는 사용자의 기본 시스템 설정에서 light mode를 선호하는지 dark mode를 선호하는지를 확인합니다.
prefers-color-scheme은 media feature의 하나로서 사용자가 선호하는 색상모드를 감지하는 key입니다.
실제로 브라우저 환경에서 matchMedia에 대한 console을 찍어보면 다음과 같은 결과를 얻습니다.
우리는 boolean 형태로 값이 담긴 matches에 접근하여 if문의 조건으로 사용하겠다는 의미였구나.
라고 위에서 작성된 코드를 이해할 수 있어졌습니다.
if (
localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
)
즉 이 코드는 만약 localStorage의 theme이 dark이거나
localStorage에 theme key 가 존재하지않으면서(웹사이트에 첫 방문시엔 로컬스토리지가 비어있을것입니다.)
window.matchMedia를 통해 사용자의 OS에 설정된 사용자가 선호하는 dark 모드 설정에 접근할 수 있다면
코드를 실행해라. 라는 의미로 해석할 수 있게 됩니다
🌞useState를 이용해 버튼을 클릭하면 다크모드를 바꿀 수 있게
src/App.tsx
import { useState, useEffect } from 'react';
import './App.css';
function App() {
const localStorageCheker = (): boolean => {
if (!localStorage.theme) return false;
return localStorage.theme === 'dark' ? true : false;
};
const [dark, setDark] = useState(localStorageCheker());
const darkSetButton = () => {
setDark((state) => {
const update = !state;
if (update) {
localStorage.theme = 'dark';
} else {
localStorage.theme = 'light';
}
return update;
});
};
useEffect(() => {
if (
localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [dark]);
return (
<div className="App">
<div className="dark justify-center bg-black dark:bg-red-300">it goes Work!!</div>
<button onClick={darkSetButton}>{dark ? '현재 Dark모드' : '현재 light모드'}</button>
</div>
);
}
export default App;
그럼 useState를 이용하여 버튼을 클릭하면 다크 모드 설정을 바꿀 수 있게 코딩해보겠습니다.
공식문서의 가이드를 따르면서 setState 함수가 동작할때마다 localStorage를 업데이트해줬습니다.
꽤 잘 동작하는 코드가 되었지만 아직 살짝의 문제점이 있습니다.
다크모드를 관리하는 로직이 꽤 길어서 예쁘지가 않네요
커스텀훅으로 만들어서 따로 관리를 해보겠습니다.
😋커스텀훅 useDarkMode를 만들어보기
src/hooks/useDarkMode.tsx
import { useState, useEffect } from 'react';
const useDarkMode = (): [boolean, () => void] => {
const localStorageChecker = (): boolean => {
if (!localStorage.theme) return false;
return localStorage.theme === 'dark' ? true : false;
};
const [dark, setDark] = useState(localStorageChecker());
const darkSetButton = () => {
setDark((state) => {
const update = !state;
if (update) {
localStorage.theme = 'dark';
} else {
localStorage.theme = 'light';
}
return update;
});
};
useEffect(() => {
if (
localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [dark]);
return [dark, darkSetButton];
};
export default useDarkMode;
아까전의 로직을 그대로 빼와서 하나의 커스텀 훅으로 만들어주었습니다.
이제 App.tsx로 돌아가서 우리가 만든 커스텀훅을 사용해주겠습니다.
import { useState, useEffect } from 'react';
import './App.css';
import useDarkMode from './components/hooks/useDarkMode';
function App() {
const [dark, toggleDarkMode] = useDarkMode();
return (
<div className="App">
<div className="dark justify-center bg-black dark:bg-red-300">it goes Work!!</div>
<button onClick={toggleDarkMode}>{dark ? '현재 Dark모드' :'현재 light모드'}</button>
</div>
);
}
export default App;
로직이 훨씬 간결해졌습니다.
useDarkMode를 호출하는 것만으로 위의 코드들을 분리하면서
우리가 원하는 기능을 구현하게 되었습니다.
그런데 사실 이 useDarkMode 훅에는 치명적인 단점도 존재합니다.
바로 useDarkMode를 재사용하는 경우 예상치 못한 버그가 발생할 수 있다는 것입니다.
각 useState들이 useDarkMode를 호출한만큼 생성되기 때문에
여러 컴포넌트에서 useDarkMode를 호출해서 관리하게되면 버그가 발생합니다.
왜 그런지 바로 이해가 안되시는 경우 실제로 실습해보시는 것을 추천드립니다.
이는 사실 전역적으로 상태가 관리되어야 맞는 DarkMode 설정에서는 문제가 있을 수도 있습니다.
재사용이 불가능한 형태의 커스텀훅이기 때문에 커스텀훅으로 만드는 가치는
로직을 분리하는데에 존재하게 됩니다.
로직을 분리하는것이 가치가 없지는 않으니 경우에 따라 가치있는 솔루션이 될 수 있고
작은 규모의 애플리케이션에서는 다크모드 하나를 위해
redux와 같은 상태관리 라이브러리를 도입하는 것은 너무 불편한 일이 될 수 있습니다.
따라서 커스텀 훅으로 만들어 상태끌어올리기와 prop내려주기를 통해 관리해도 좋지만
추후에 복잡하고 큰 규모의 프로젝트로 발전될 여지가 있는 경우에는
전역상태관리라이브러리를 이용해 관리해주는 것도 좋은 대안이 될것입니다.
이번엔 redux toolkit을 이용해 동일한 로직을 전역적으로 관리해보겠습니다.
🤢@reduxjs/toolkit을 이용해 전역 다크모드 관리
npm install react-redux @reduxjs/toolkit
우선 프로젝트에 react-redux와 툴킷을 설치해주겠습니다.
yarn을 사용하시는 분들은 yarn을 이용하셔도 무방합니다.
폴더 구조는 다음과 같이 작성하겠습니다.
src/modules 디렉토리에서 스토어에 관련된 설정을 모두 해주도록하겠습니다.
src/modules/darkSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface DarkModeState {
isDark: boolean;
}
const initialState: DarkModeState = {
isDark:
localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches),
};
const darkSlice = createSlice({
name: 'darkSlice',
initialState,
reducers: {
toggleDarkMode: (state, action: PayloadAction<string>) => {
const update = !state.isDark;
console.log(action.payload);
if (update) {
localStorage.theme = 'dark';
} else {
localStorage.theme = 'light';
}
state.isDark = update;
},
},
});
export const { toggleDarkMode } = darkSlice.actions;
export default darkSlice;
createSlice하는 부분은 typeScript에서도 유사하게 작성할 수 있습니다.
initialState의 상태를 정의해주는 것과
action 함수의 타입을 정의해줄 payloadAction을 import해서 action 함수를 타이핑해주는게 핵심입니다.
PayloadAction<string>과 같은 형태로 payloadAction의 타입을 지정해줄 수 있다
라는 것을 보여드리기 위해 실제로는 action의 payload에 상관없이
isDark의 값을 반전시키는 일만 수행하지만 string 타입을 받도록 설정해줬습니다.
이외에는 자바스크립트와 거의 동일하니 넘어가겠습니다.
src/modules/index.ts
import { configureStore } from '@reduxjs/toolkit';
import darkSlice from './darkSlice';
const store = configureStore({
reducer: {
dark: darkSlice.reducer,
},
});
export default store;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
이 부분은 조금 중요합니다. RootState를 export해줘야합니다.
AppDispatch는 필요하신 경우에만 활용하시면 됩니다.
RootState는 사용하지않으면 useSelector 혹은 connect함수의 첫번째 콜백함수를 사용할 때
받아오는 state의 type을 알수 없어지기 때문에 반드시 지정해주어야하는 부분이라는 점
기억하고 가겠습니다.
src/App.tsx
import { useState, useEffect } from 'react';
import './App.css';
import { Provider } from 'react-redux';
import store from './modules';
import Header from './components/header/Header';
function App() {
return (
<Provider store={store}>
<div className="App">
<Header />
<div className="dark justify-center bg-black dark:bg-red-300">it goes Work!!</div>
<div className=" flex items-center justify-center bg-rose-500"></div>
</div>
</Provider>
);
}
export default App;
우리가 만든 store와 react-redux가 제공하는 Provider를 import 하겠습니다.
연결을 해준 뒤 실제 사용은 Header 컴포넌트에서 할것입니다.
Header 컴포넌트도 만들어보겠습니다.
src/components/header/Header.tsx
import * as React from 'react';
import { connect, ConnectedProps } from 'react-redux';
import useDarkMode from '../hooks/useDarkMode';
import { RootState } from '../../modules/index';
import { toggleDarkMode } from '../../modules/darkSlice';
const { useEffect } = React;
type Props = {
children?: React.ReactNode;
};
type PropsFromRedux = ConnectedProps<typeof connector>;
type ComponentProps = Props & PropsFromRedux;
const Header = ({ dark, toggleDarkMode }: ComponentProps) => {
useEffect(() => {
if (dark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [dark]);
return (
<header className=" flex justify-between px-5 ">
<div>CoinBase</div>
<div className=" flex px-5 py-2 ">
<form>
<input
className=" rounded-full border border-solid border-blue-500 p-1 text-center hover:bg-blue-500 hover:text-white"
type="text"
placeholder="typing something"
/>
</form>
<div className="ml-7 p-1">User Name</div>
</div>
<div>
<button
onClick={() => {
toggleDarkMode('이렇게전달해요');
}}
>
{dark ? '다크모드상태에요' : '라이트모드상태에요'}
</button>
</div>
</header>
);
};
const connector = connect((state: RootState) => ({ dark: state.dark.isDark }), { toggleDarkMode });
export default connector(Header);
실제 DOM을 조작하는 로직은 useEffect 내부에서 이루어져야합니다.
따라서 컴포넌트 단에서 useEffect를 통해 조작해주겠습니다.
typescript에서 connect 함수를 이용하기 위해서는 위와 같은 사전작업이 필요합니다.
ConnectedProps | react-redux가 제공해주는 타입입니다. redux가 제공해준 상태, 디스패치함수들의 타입을 정의하는데 사용합니다. |
Props | 제가 임의로 만든 Props에 대한 타입입니다. children 혹은 부모 컴포넌트로부터 받을 수 있는 props들을 타이핑해줬습니다. |
ComponentProps | ConnectedProps와 Prop 타입을 결합하여 실제 Props를 받는 영역에 적용해줍니다. |
RootState | index.ts 부분에서 정의한 리덕스에 저장되어있는 상태들의 타입입니다. connect함수의 첫번째 인자의 콜백함수가 받는 state 인자의 타입으로 사용합니다. |
이제 해야하는 모든 작업은 끝났습니다.
잘 작동하는 지 실제 프로젝트에서 확인해보세요
그런데 이렇게 코드를 짜고보니 살짝 투머치하다는 생각이 듭니다.
이렇게 코드를 작성해버리면 항상 다크모드가 필요할때마다
useEffect를 이용해서 작성해주고 connect로 귀찮게 연결을 해줘야할것같네요
이때야말로 커스텀훅을 만들어주는게 좋을 것 같습니다.
그런데 connect는 컴포넌트에 연결을 해주는 목적을 가진 함수다 보니
커스텀훅으로 로직을 짜는 것에 살짝 애로사항이 있을 것 같네요
useSelector와 useDispatch를 이용해서 커스텀훅 로직을 작성해보겠습니다.
🤮손님? 리덕스툴킷에 커스텀훅을 얹어먹어보시겠습니까?
src/hooks/useDarkMode.tsx
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../../modules/index';
import { toggleDarkMode } from '../../modules/darkSlice';
export type useDark = [boolean, (text: string) => void];
const useDarkMode = (): useDark => {
const isDark = useSelector((state: RootState) => state.dark.isDark);
const dispatch = useDispatch();
useEffect(() => {
if (isDark) {
localStorage.theme = 'dark';
document.documentElement.classList.add('dark');
} else {
localStorage.theme = 'light';
document.documentElement.classList.remove('dark');
}
}, [isDark]);
const onToggleDarkMode = (text: string): void => {
dispatch(toggleDarkMode(text));
};
return [isDark, onToggleDarkMode];
};
export default useDarkMode;
useSelector와 useDispatch를 사용하는 경우에는 타입을 설정하는게 많이 까다롭지 않습니다.
객체를 내보내주는 커스텀훅으로 작성하게 되면 프로퍼티의 이름을 기억하는게 번거로울테니
배열로 만들어 내보내주고자 했습니다.
로직 자체는 위에서 만들었던 커스텀 훅과 매우 유사합니다.
다만 type값을 만들어서 내보내기해주는 것과
useSelector, useDispatch를 사용하는 점이 가장 크게 다른 점일 것입니다.
두가지 값을 내보낸다는 점에 착안하여 튜플의 형태로 타이핑해줬습니다.
export type useDark = [boolean, (text: string) => void];
이렇게 작성할 수 있어요!
이제 잘 작성했다면 우리의 커스텀훅을 사용할 컴포넌트를 만들어봅시다.
src/components/main/main.tsx
import * as React from 'react';
import useDarkMode, { useDark } from '../hooks/useDarkMode';
export const Main = () => {
const [isDark, onToggleDarkMode]: useDark = useDarkMode();
return (
<div>
<h2>{isDark ? '다크모드상태에요' : '다크모드상태아니에요'}</h2>
<button
onClick={(): void => {
onToggleDarkMode('');
}}
>
다크모드바꾸는메인버튼
</button>
</div>
);
};
커스텀훅 단에서 만들어줬던 타입을 임포트해서
우리가 반환받는 배열에 적용시켜주겠습니다. 딱 이 일만 추가적으로 수행해주면 끝입니다.
이제 어디서든 재사용 가능하면서 전역적으로 관리되는 다크모드를 구현하는 것에 성공했습니다.
🐶마치며
간단한 기능만 구현해보고 끝내는 것이 아니라 실제로도 유용하게 사용할 수 있을만큼
기능을 만들어나가는 경험이 큰 도움이 된 것 같습니다.
다크모드 관리는 사용자 경험에 큰 영향을 미치는 만큼 잘 사용할 수 있을 것 같아요!
'css' 카테고리의 다른 글
tailwind-merge 사용법을 익히고 클래스 병합하기 (2) | 2023.05.20 |
---|---|
Tailwindcss를 더 영리하게 사용하기 (0) | 2023.05.06 |
자주 사용하는 tailwind css class cheat sheet (0) | 2023.04.24 |
tailwind css를 vite react project에 도입하기 (0) | 2023.04.23 |
css border 기능으로 테두리만 있는 말풍선, 꽉찬 말풍선 만드는 사이트 (0) | 2022.11.21 |