개요
context API를 학습하기 위해 다크모드 on/off 기능을 만들어보기로 했다.
스위치를 토글하면 이미지가 해 -> 달로 바뀌고 테마 색이 바뀌는 간단한 사이트이다. 순수하게 React의 기능만 사용해 만들고 싶어서 styled-components를 쓰지 않고 만들었다.
만들면서 참조한 글 (Thanks to)
Context API란
우리가 Context API라고 하는 것은 js의 history API처럼 Context 라는 기능을 사용할 수 있도록 해주는 기능 모음을 의미한다. Context는 전역적으로 컴포넌트끼리 값을 공유할 수 있도록 해주는 기능이다.
props drilling 없이 상위의 Context에서 깊은 곳에 위치한 컴포넌트로 직접 값을 전달할 수 있다. Redux와 다른 점은 redux는 store를 한 개만 가질 수 있지만, Context API는 Provider라는 데이터의 원천을 필요한 곳에 단위별로 둘 수 있다는 것이다.
Context.Provider는 값을 저장하고 있는 store 같은 역할을 하며, 하위에 위치한 컴포넌트는 Context.Consumer 혹은 useContext() hook을 이용해 값을 참조할 수 있다. 보통은 더 간편한 useContext hook을 더 많이 쓴다.
공식 문서 - Context API, useContext() hook
1. 기본 세팅
🔨폴더 구조
🔨마크업
지금은 화면 모양을 잡기 위해 대충 이렇게 마크업 해준다. 나중에는 <div> 부분을 별도의 컴포넌트로 분리하고 sunImage와 텍스트 부분도 light / dark에 따라 바뀌게 할 것이다.
// App.js
import sunImage from '../src/assets/sun.png';
import moonImage from '../src/assets/moon.png';
import ToggleButton from './components/ToggleButton';
function App() {
return (
<div className="container" style={containerStyle}>
<header style={headerStyle}>
<img src={sunImage} alt="" />
<ToggleButton />
<p style={textBoxStyle}>It's daytime!</p>
</header>
</div>
);
}
export default App;
🔨테마 정의하기 - styles/theme.js
// styles/theme.js
export const lightTheme = {
background: '#FAF8F3',
switchBackground: '#FFFFFF',
switch: '#FFCC33',
highlight: '#9DC6F3',
font: '#000000',
};
export const darkTheme = {
background: '#404258',
switchBackground: '#474E68',
switch: '#FFCFDE',
highlight: '#5566AA',
font: '#FFFFFF',
};
2. ThemeProvider 작성하기
contexts 폴더에 ThemeProvider 파일을 생성한다.
ThemeProvider 컴포넌트는 ThemeContext.Provider의 wrapper이다. 내부에 themeMode (현재 테마가 무엇인지) 상태를 갖고 있으면서 상태에 따라 theme 객체를 value에 전달한다.
// contexts/ThemeProvider.js
import { createContext, useContext, useState } from 'react';
import { lightTheme, darkTheme } from '../styles/theme';
const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [themeMode, setThemeMode] = useState('light');
const theme = themeMode === 'light' ? lightTheme : darkTheme;
return (
<ThemeContext.Provider value={{ themeMode, setThemeMode, theme }}>
{children}
</ThemeContext.Provider>
);
}
const useTheme = () => {
const context = useContext(ThemeContext);
const { themeMode, setThemeMode, theme } = context;
const toggleTheme = () => {
setThemeMode(themeMode === 'light' ? 'dark' : 'light');
};
return [ themeMode, toggleTheme, theme ];
};
export { ThemeProvider, useTheme };
useTheme() hook은 context를 가져오는 반복작업을 간편하게 하기 위한 hook이다. context 값이 필요한 곳에서 호출만 하면 부가 작업을 안 해도 되고 setThemeMode를 토글 함수로 가공해서 주기 때문에 편리하다.
ThemeProvider의 값을 이용할 때 사용하는 함수이기 때문에 편의를 위해 ThemeProvider 안에 위치시켰다.
3. 다크 모드 on/off 구현
💻Provider 적용 / 테마 가져오기
앞에서 마크업 할 때 작성했던 <div> 부분을 별도의 컴포넌트(MainPage)로 분리해주고 ThemeProvider로 감싸주었다.
// App.js
function App() {
return (
<ThemeProvider>
<MainPage />
</ThemeProvider>
);
}
미리 정의해놓은 useTheme()을 호출해 useContext()를 하고, 가져온 theme을 적용해주자. ThemeProvider에서 themeMode에 따라 theme 객체에 해당하는 스타일을 담아놓았기 때문에 현제 테마가 무엇인지 추가로 검사할 필요 없이 그냥 갖다 쓰면 된다.
// pages/MainPage.js
import { useTheme } from '../contexts/ThemeProvider';
import sunImage from '../assets/sun.png';
import moonImage from '../assets/moon.png';
import ToggleButton from '../components/ToggleButton';
function MainPage() {
const [themeMode, toggleTheme, theme] = useTheme();
return (
<div
className="container"
style={Object.assign({}, containerStyle, {
backgroundColor: theme.background,
})}
>
<header className="header" style={headerStyle}>
<img src={themeMode === 'light' ? sunImage : moonImage} alt="" />
<ToggleButton toggleTheme={toggleTheme} theme={theme} />
<p
style={Object.assign({}, textBoxStyle, {
color: theme.font,
background: `linear-gradient(to top, ${theme.highlight} 40%, transparent 40%)`,
})}
>
{themeMode === 'light' ? "It's daytime!" : "It's night!"}
</p>
</header>
</div>
);
}
style은 아래와 같은 default 스타일과 테마에 따라 바뀌는 속성을 Object.assign()을 통해 합성해주는 방식이다.
const containerStyle = {
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center',
};
💻스위치 토글 애니메이션 추가하기
스위치도 똑같이 테마에 따라 스위치 배경과 버튼색이 바뀌게 구현했다.
function ToggleButton({ toggleTheme, theme }) {
return (
<div
style={{ display: 'flex', justifyContent: 'center', marginRight: '12px' }}
>
<input type="checkbox" id="toggle" hidden onClick={toggleTheme} />
<label
htmlFor="toggle"
className="toggleSwitch"
style={Object.assign({}, toggleSwitchStyle, {
backgroundColor: theme.switchBackground,
})}
>
<span
className="toggleButton"
style={Object.assign({}, toggleButtonStyle, {
backgroundColor: theme.switch,
left: theme.switchLeft,
})}
></span>
</label>
</div>
);
}
스위치 배경 내부에 버튼이 absolute로 떠있는 구조이기 때문에, 스위치를 오른쪽으로 옮기려면 left 값을 더 주면 된다.
일단 스위치 버튼이 부드럽게 움직이도록 버튼 스타일에 transition 속성을 추가해준다.
const toggleSwitchStyle = {
width: '80px',
height: '40px',
display: 'block',
position: 'relative',
borderRadius: '30px',
boxShadow: '0 0 16px 3px rgba(0 0 0 / 15%)',
cursor: 'pointer',
};
const toggleButtonStyle = {
width: '32px',
height: '32px',
position: 'absolute',
top: '50%',
left: '4px',
transform: 'translateY(-50%)',
borderRadius: '50%',
transition: 'all 0.2s ease-in', // 부드럽게 움직이도록 추가
};
그리고 테마에 따라 left 값을 다르게 준다.
export const lightTheme = {
// ...
switchLeft: '4px',
};
export const darkTheme = {
// ...
switchLeft: 'calc(100% - 40px)',
};
calc(100% - 40px)인 이유는 100%을 하면
이렇게 스위치 배경의 width만큼 left가 부여되기 때문에, 버튼 자신의 크기만큼 왼쪽으로 옮겨주기 위함이다.
완료!
4. localStorage로 테마 유지하기
새로고침을 하면 리렌더링이 되면서 Provider에 있던 value도 초기화된다. 따라서 값을 저장하기 위해 localStorage를 이용할 것이다.
Provider에서 초기값으로 localStorage에 저장된 테마를 불러온다. 이미 저장된 값이 없어서 null인 경우에는 기본값으로 light를 갖도록 한다.
function ThemeProvider({ children }) {
const localTheme = window.localStorage.getItem('theme') || 'light';
const [themeMode, setThemeMode] = useState(localTheme);
const theme = themeMode === 'light' ? lightTheme : darkTheme;
return (
<ThemeContext.Provider value={{ themeMode, setThemeMode, theme }}>
{children}
</ThemeContext.Provider>
);
}
테마가 토글될 때마다 localStorage에 변경사항을 저장한다.
const useTheme = () => {
const context = useContext(ThemeContext);
const { themeMode, setThemeMode, theme } = context;
const toggleTheme = () => {
window.localStorage.setItem(
'theme',
themeMode === 'light' ? 'dark' : 'light'
);
setThemeMode(themeMode === 'light' ? 'dark' : 'light');
};
return [themeMode, toggleTheme, theme];
};
마무리
❌ Provider와 useContext 사용할 때 주의점
처음에 아래 코드처럼 작성했더니 useContext로 값을 가져올 때 의도한 대로 배경색이나 이미지, 폰트색은 바뀌지 않고 ToggleButton에만 테마가 적용되었다. 이유를 찾아보니 Context API의 취지 때문이었다.
// 틀린 예시
function App() {
const [themeMode, toggleTheme, theme] = useTheme();
return (
<ThemeProvider>
<div className="container"
style={Object.assign({}, containerStyle, {
backgroundColor: theme.background,
})}
>
<header className="header" style={headerStyle}>
<img src={sunImage} alt="" />
<ToggleButton toggleTheme={toggleTheme} theme={theme} />
<p tyle={textBoxStyle}>"It's It's daytime!"</p>
</header>
</div>
<ThemeProvider/>
);
}
Context API는 상위 컴포넌트에서 하위 컴포넌트로 값을 전달하기 위해 만들어진 것이기 때문에, <ToggleButton>만 theme을 받아볼 수 있고 별도의 컴포넌트로 분리되지 않은 아래의 <div> 같은 경우에는 theme 값을 받아올 수 없다.
<div> 이하를 별도의 페이지 컴포넌트로 분리한 이유가 바로 이때문이다.
// 올바른 예시
function App() {
return (
<ThemeProvider>
<MainPage />
</ThemeProvider>
);
}
Provider와 useContext()는 같은 컴포넌트 레벨에서는 사용할 수 없다
❌ React에서 inline style을 Object.assign()으로 합성할 때 주의점
inline-style을 Object.assign()으로 합성하려다가 다음과 같은 에러를 마주했다.
Cannot assign to read only property 'backgroundColor' of object '#<Object>'
원인은 React의 style은 읽기 전용 프로퍼티이기 때문이다. Object.assign()을 이용해 변경하려면 기존 스타일에 합성하는 것이 아니라 새로운 객체를 만들어 할당함으로써 해결할 수 있다.
// 틀린 예시
style={Object.assign(toggleSwitchStyle, {
backgroundColor: theme.switchBackground,
})}
// 올바른 예시
style={Object.assign({}, toggleSwitchStyle, {
backgroundColor: theme.switchBackground,
})}
✅ inline-style을 사용했다면?
위에서 만든 Context API에서는 이렇게 세 가지 값을 value로 전달하고 있다.
<ThemeContext.Provider value={{ themeMode, setThemeMode, theme }}>
만약 inline-style 라이브러리를 이용했다면 자체적으로 제공하는 ThemeProvider 기능을 통해 스타일링과 관련된 값을 따로 관리할 수 있었을 것이다. 그리고 context를 가져오는 코드도 theme을 가져오지 않아도 되서 작성해야 하는 코드가 엄청 줄어들었을 것이다.
// Provider
<ThemeContext.Provider value={{ ThemeMode, setThemeMode }}>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</ThemeContext.Provider>
// Context 사용하는 곳
const [themeMode, toggleTheme] = useTheme();
// styled-components의 ThemeProvider에서 theme 가져오는 법
const ContainerStyle = styled.div`
backgroundColor: ${props => props.theme === 'background'}
`
💙 소감
위에서 쓴 것처럼 styled-components를 사용했다면 지저분하게 인라인 스타일링과 Object.assign()으로 씨름하지 않아도 되고, ThemeProvider로 간편하게 theme을 관리할 수 있었을 것이다.
하지만 별도로 라이브러리를 쓰지 않고 구현해서 React에 대해 더 자세히 탐구할 수 있었고, 스타일링 라이브러리가 어떤 필요로 인해 개발되었는지, 특히 전역 스타일 관리의 필요성에 대해 깨달음을 얻을 수 있었던 것 같다.
이렇게 작은 프로젝트를 만들면서 학습하니까 재미도 있고, Context API가 뭔지 추상적인 개념만 알고 있었는데 구체적인 사용법부터 모듈화하고 hook 만들어서 활용하는 법까지 연습할 수 있어서 좋은 것 같다.
'프로젝트 회고' 카테고리의 다른 글
[Project] 포카리뷰 (Phoca Review) 만들기 회고 | 토이프로젝트 (0) | 2023.03.30 |
---|---|
[Project] React Login 프로젝트로부터 배운 점 회고 (0) | 2022.09.07 |
[Project]Vanilla JS - Todo 리스트 만들기 (0) | 2022.04.25 |