다크모드를 만들었습니다!
캠프에 있을 때, 프리프로젝트와 메인프로젝트에서 '상태관리를 합시다!'라고 해놓고 안한 적이 있습니다. 상태 관리 과제같은 걸 할 때는 전역 상태 관리가 필요한 부분이 (당연히) 여럿 있어서 뚝딱뚝딱 적용했는데, 막상 백엔드와 함께 애플리케이션을 구현하니 서버에서 다 해주니까 딱히 전역 상태 관리를 적용할 만한 부분이 없었던 겁니다. 전역으로 관리되어야 하는 정보들은 서버에 저장하는 게 당연히 합리적이니까요. 당시 멘토님께서는
전역 상태 관리 없이는 구현하기 힘들 거라 생각했다. UI적으로도 전역 상태 관리가 필요한 부분들이 많은데, 캐치를 못한 것 같다
라고 피드백을 주셨습니다. 제 개인적인 스승님께서는,
상태 관리의 필요성을 못 느꼈으면 안 써도 된다. 필요할 때 쓰는 거다.
네가 생각한대로 전역에서 관리되어야 할 만큼 중요한 정보들은 당연히 어딘가에 저장되어야 하고, 그 역할을 백엔드에서 해줄 수 있다면 별다른 특이 조건 없이는 그렇게 하는 게 맞다.
다만, 전역 상태 관리가 필요한 부분이 어느 곳인지, 어떨 때 써야 하는지 정도는 주의하면서 구현하는 게 맞다.
고 하셨구요. 그래서 전역 상태 관리가 어디에 필요할까 생각하다가 아래와 같이 정리해보았습니다.
이정도가 아닐까 싶었습니다. 관리는 전역에서 해야 하지만, 그 정보를 저장해둘 만큼 중요하지 않은 경우라... 제가 생각한 예시는 '언어 설정'이나 '다크 모드'같은 것이었습니다. UI상으로 중요한 부분이고, 애플리케이션 전역에 적용되어야 하지만, 그 내용이 서버에서 저장해둘 만큼 중요하지는 않으니까요. 그래서 이번에 포트폴리오 프로젝트에서 redux-toolkit으로 다크모드를 만들어보기로 했습니다.
일단 store를 설정합니다.
import { configureStore } from "@reduxjs/toolkit";
import darkModeReducer from "./slices/darkModeSlice";
const preloadedState = {
darkMode: sessionStorage.getItem("darkMode") === "true" ? true : false,
};
export const store = configureStore({
preloadedState,
reducer: {
darkMode: darkModeReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
그리고 slice를 구현합니다.
import { createSlice } from "@reduxjs/toolkit";
export const darkModeSlice = createSlice({
name: "darkMode",
initialState: false,
reducers: {
setDarkMode: (state, action) => action.payload,
toggleDarkMode: (state) => !state,
},
});
export const { setDarkMode, toggleDarkMode } = darkModeSlice.actions;
export default darkModeSlice.reducer;
새로고침을 했을 때 다크모드가 초기화되는 걸 방지하기 위해, 세션스토리지에 저장된 다크모드 관련 정보와 연동해서 작동하도록 해주었습니다. 이제 다크모드가 변경될 때 이를 세션스토리지에 저장하기 위한 기능이 필요합니다.
import { ReactNode, useEffect } from "react";
import { useDispatch } from "react-redux";
import { useDarkMode } from "./hooks/useDarkMode";
import { setDarkMode } from "../redux/slices/darkModeSlice";
interface DarkModeWrapperProps {
children: ReactNode;
}
export default function DarkModeWrapper({ children }: DarkModeWrapperProps) {
const darkMode = useDarkMode();
const dispatch = useDispatch();
useEffect(() => {
const savedDarkMode = sessionStorage.getItem("darkMode");
if (savedDarkMode !== null) {
dispatch(setDarkMode(savedDarkMode === "true"));
}
}, [dispatch]);
useEffect(() => {
sessionStorage.setItem("darkMode", darkMode.toString());
}, [darkMode]);
return <>{children}</>;
}
원래는 이 내용을 useDarkModeWithStorage라는 커스텀 훅으로 구현했습니다. darkMode라는 boolean을 반환하는 데에는 useDarkModeState라는 커스텀 훅을 사용했구요. '다크모드-세션스토리지 동기화' 로직과 '다크모드 상태 불러오기'로직은 별개라고 생각했기 때문입니다.
그러나 네이밍이 직관적이지 않고, 굳이 커스텀 훅으로 App.tsx에서 불러와서 사용할 필요성이 마땅하지 않아서, DarkModeWrapper라는 HOC를 만들어 App.tsx에서 하위 요소를 감싸주는 식으로 수정했습니다.
function App() {
const darkMode = useDarkMode();
const canvasRef = useGrain(darkMode);
return (
<DarkModeWrapper>
<canvas
ref={canvasRef}
className="absolute top-0 left-0 w-full h-full z-10 opacity-5 pointer-events-none"
/>
<Header />
<Routes>
<Route path="/" element={<Main />} />
<Route path="/about" element={<About />} />
<Route path="/skills" element={<Skills />} />
<Route path="/projects" element={<Projects />} />
<Route path="/contacts" element={<Contacts />} />
</Routes>
</DarkModeWrapper>
);
}
이렇게요!
이런 식으로 했습니다
export default function Main() {
const darkMode = useDarkMode();
return (
<div
className={`${
darkMode ? "text-white bg-slate-500" : "text-gray-700"
} w-screen h-screen flex flex-col sm:flex-row justify-center items-center`}
>
<div className="flex justify-center items-center w-full h-1/5 sm:w-2/5 text-xl sm:text-2xl md:text-3xl lg:4xl xl:text-4xl">
프론트엔드 개발자 이의현
<br />
그른손thewronghand의 포트폴리오입니다.
</div>
<div className="w-full h-1/5 sm:w-1/5 flex justify-center items-center ">
<Nav />
</div>
</div>
);
}
useDarkMode 훅으로 darkMode 불린을 불러오고, 삼항연산자로 darkMode가 true일 때에는 text-white와 bg-slate-500을 적용하고, false이면 text-gray-700을 적용합니다.
다크모드 버튼 컴포넌트를 만들었습니다.
export default function DarkModeButton() {
const dispatch = useDispatch();
const darkMode = useDarkMode();
return (
<button onClick={() => dispatch(toggleDarkMode())}>
{darkMode ? "라이트모드" : "다크모드"}
</button>
);
}
클릭이벤트로 다크모드 전역 상태를 바꿔줍니다!
이 다크모드 버튼 컴포넌트를 Header에 띄워주고, 어느 페이지에서든 다크모드/라이트모드 토글이 가능하게끔 만들어주었습니다.
여기까지 해놓고 나서 발견한 문제는, 지난 포스팅에서 useGrain 훅으로 구현한 필름 그레인 효과가 다크모드에서는 제대로 보이지 않는다는 점이었습니다. useGrain 훅에서는 검은 픽셀을 무작위로 생성해서 화면에 뿌리는데, 화면이 어두운 상태에서는 검은색+opacity 50%의 그레인 입자들이 보이지 않는겁니다. 그래서 다크모드가 on일 때에는 생성되는 그레인 입자를 흰 색으로 바꿔주는 로직을 추가했습니다.
const createNoise = (
ctx: CanvasRenderingContext2D,
wWidth: number,
wHeight: number,
darkMode: boolean
): ImageData => {
const iData = ctx.createImageData(wWidth, wHeight);
const buffer32 = new Uint32Array(iData.data.buffer);
const len = buffer32.length;
const color = darkMode ? 0xffffffff : 0xff000000; // 흰색 또는 검은색
for (let i = 0; i < len; i++) {
if (Math.random() < 0.1) {
buffer32[i] = color;
}
}
return iData;
};
createNoise함수가 darkMode를 인자로 받게 하고, darkMode가 true이면 생성되는 입자의 색깔을 흰색으로 설정합니다. useGrain 훅도 darkMode:boolean 인자를 받게 해서, 사용할 때에는 아래와 같이 쓸 수 있습니다.
const darkMode = useDarkMode();
const canvasRef = useGrain(darkMode);
이렇게!
이로써 다크모드에서도 필름 그레인 효과가 잘 적용되게끔 개선되었습니다!
짜잔~
그렇게 엄청 티는 안나지만! 잘 적용돼서 뿌듯합니다! 3배정도 확대해서 보면 보입니다!!