티스토리 뷰
Storybook이란?
Storybook: Frontend workshop for UI development
Storybook is a frontend workshop for building UI components and pages in isolation. Thousands of teams use it for UI development, testing, and documentation. It’s open source and free.
storybook.js.org
스토리북(Storybook)은 프론트엔드 개발에 사용되는 인기있는 오픈 소스 도구입니다. 주로 UI 컴포넌트를 독립적으로 개발, 테스트 및 문서화하는 데 도움이 됩니다.
저 같은 경우에도 공용 컴포넌트를 만들 때 당장 사용하면서 만드는 것이 아니였기에 "이 정도면 됐겠지?.." 하고 만들었던 기억도 있고, 공용 컴포넌트가 많아지면 팀원들끼리 세세한 속성까지는 공유가 되지 못하는 상황도 있었습니다. 스토리북을 사용하면서 이러한 점을 해소할 수 있었습니다. 문서화 하며 관리할 수 있었고 시각적으로 case 별로 관리할 수 있어 UI 컴포넌트에 효과적이라고 느꼈던 것 같습니다.
다음은 스토리북의 주요 특징입니다.
1. 컴포넌트 중심 개발
Storybook을 사용하면 개별 컴포넌트를 독립적으로 개발할 수 있습니다. 각 컴포넌트를 분리된 환경에서 개발함으로써, 다양한 조건과 상태를 쉽게 시뮬레이션하고 디버깅할 수 있습니다.
2. 스토리 작성
스토리는 특정 컴포넌트의 사용 사례를 예시하는 스냅샷으로, 쉽게 말해서 스토리북에서의 스토리(Story)는 개별 컴포넌트에 대한 인스턴스의 구성을 의미합니다. 더 쉽게 말해서는 Button 컴포넌트에 대해서 우리가 주로 사용하는 속성들을 모아서 구성해 놓는데 이런 것들을 스토리라고 할 수 있겠습니다.
3. 생산성 향상
스토리북을 사용하면 생산성이 크게 향상됩니다. 전체 어플리케이션을 컴파일해 볼 필요 없이 개별 컴포넌트를 독립적으로 빌드하고 테스트할 수 있어 개발 시간을 절약할 수 있기 때문입니다.
4. 문서화
스토리북은 자동 문서화 기능을 제공하여 독립된 컴포넌트 상태를 손쉽게 시각화하고 관리할 수 있습니다. 이를 통해 개발자, 디자이너, 제품 관리자 등 팀웓르이 컴포넌트의 현재 상태 및 작동 방식을 이해하고 협업할 수 있습니다.
Storybook 설치 및 작동
우선 init을 해줘야 합니다.
$ npx storybook init
작동하는 방법은 다음과 같습니다.
$ npm run storybook
init 했을 때 생기는 파일 - main.js & preview.js 파일
1. main.js
이 파일은 Storybook의 핵심 설정 파일입니다. 여기에서는 다양한 설정을 조정할 수 있는데요.
기본적으로 라우팅, 스토리의 파일 패턴, 애드온 추가 등을 설정할 수 있습니다. 주로 기본적으로 아래와 같이 storybook으로 생성될 확장자를 추가합니다.
📜 .storybook/main.js 파일
const config = {
stories: ["../stories/**/*.mdx", "../stories/**/*.stories.@(js|jsx|ts|tsx|mdx)"],
...
}
export default config
❓ 애드온은 뭘까
스토리북 애드온(addon)은 스토리북의 기능을 확장하고 사용자 경험을 더욱 좋게 만드는 플러그인데요. 쉽게 말해, 필요한 기능을 추가할 수 있습니다.
플러그인을 적용하고 싶다면 설치 후 addon에 추가해주면 됩니다. 아래는 예시입니다.
$ npm i -D @storybook/addon-backgrounds
// .storybook/main.js
module.exports = {
addons: [
'@storybook/addon-backgrounds',
],
};
2. preview.js
이 파일은 스토리북에서 컴포넌트들이 어떻게 보여질지와 일관되게 작동할지에 대한 전역 설정을 하는 곳입니다. 이 파일에서 설정된 내용은 모든 스토리에 공통적으로 적용됩니다. 주로 다음과 같은 경우에 사용되는데요.
- 전역 스타일(css) 추가
- 스토리에 참조하는 전역 변수 및 전역 함수(데코레이터)를 설정할 때
- 대부분의 스토리에서 사용하는 일반적인 동작이나 테마를 설정할 때
❓ 우선 전역 스타일 설정하는 것부터..
이건 쉽습니다. preview.js에서 import만 해주면 됩니다.
import '../src/index.css'; // 적용하고 싶은 css 파일을 import
❓ 데코레이터가 뭘까
위에서 스토리에 참조하는 전역 변수 및 전역 함수(데코레이터) 어쩌고 어쩌고를 설정하고 싶다면 preview.js에서 작성해주면 된다고 했는데요.
우선 데코레이터(decorator)는 컴포넌트를 감싸서 추가적인 기능, 레이아웃, 스타일, 상태를 제공하는 데 사용되는 간단한 함수를 말하는데요. 데코레이터는 주로 스토리에 일관된 패팅, 여백을 적용할 때, 스토리 컴포넌트에 context, 상태, theme를 주입할 때 사용합니다.
const customStyleDecorator = (Story) => (
<div style={{ margin: '1em' }}>
<Story />
</div>
);
export const decorators = [
customStyleDecorator,
];
데코레이터에는 여러 가지가 포함될 수 있어 위처럼 변수로 선언해주고 decorators 배열 안에 넣어주는 것이 좋습니다.
아무튼 위와 같이 작성하면 모든 스토리 부모 요소에 공통적으로 적용이 되겠습니다.
사용해보자
주로 공용 컴포넌트를 관리할 때 src/components 라는 폴더 아래에서 관리를 많이 하게 되는데요. 저는 이번에 Button을 만들어 보겠습니다.
1. styles.js 파일을 만들어서 속성 여러 개들을 정의해주자
📜 src/Components/Button/Button.styles.js 파일
import styled, { css } from 'styled-components';
const variantCSS = {
primary: css`
background-color: gray;
color: white;
border: none;
&:disabled {
background-color: rgb(220, 220, 220);
}
`,
inverse: css`
color: gray;
border: 1px solid gray;
background: none;
&:disabled {
color: rgb(220, 220, 220);
border: 1px solid gray;
}
`,
'primary-text': css`
background: none;
border: none;
color: gray;
`,
text: css`
background: none;
border: 1px solid gray;
`,
default: css`
background-color: white;
border: 1px solid gray;
&:disabled {
color: gray;
border: 1px solid gray;
}
`,
};
const shapeCSS = {
default: css`
border-radius: 0.125rem;
`,
round: css`
border-radius: 3rem;
`,
};
const sizeCSS = {
dense: css`
padding: 0, 0.5rem;
`,
small: css`
padding: 0.25rem 0.25rem;
`,
medium: css`
font-size: 1rem;
padding: 0.625rem 0.625rem;
`,
large: css`
font-size: 1.25rem;
padding: 1.25rem;
font-weight: 700;
`,
};
export const Button = styled.button`
${({ variant }) => variantCSS[variant]};
${({ shape }) => shapeCSS[shape]};
${({ size }) => sizeCSS[size]};
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
`;
위에 작성한 코드에는 정답이 없습니다.. 프로젝트 도입 전 와이어 프레임에서 자주 사용되는 버튼들의 속성들을 조사하고 prop으로 받아 사용하고 싶은 속성들을 카테고리 별로 모아두었다고 생각하시면 되겠습니다. 프로젝트를 진행하다가 필요한 컴포넌트가 생긴다면 가끔 속성을 추가하거나 하기도 합니다 :)
2. 이제 prop을 받을 컴포넌트 자체를 선언해보자
📜 src/Components/Button/Button.js 파일
위 코드에서 아래 export하고 있는 버튼을 보게 되면 variant, shape, size, ...등을 받아서 스타일을 적용하고 있습니다. 이 스타일 컴포넌트를 return 할 즉, 렌더링할 함수를 선언해 봅시다.
import * as Styled from './Button.styles';
const Button = ({
variant = 'default',
shape = 'default',
size = 'medium',
fullWidth = false,
children,
...props
}) => {
return (
<Styled.Button
variant={variant}
shape={shape}
size={size}
fullWidth={fullWidth}
disabled={!!props.disabled}
{...props}
>
{children}
</Styled.Button>
);
};
export default Button;
속성에는 디폴트 속성(제일 많이 쓰이는 속성 조합)을 해두어 디폴트 버튼에 대해서는 더 빨리 쓸 수 있도록 했습니다. 여기까지만 작성해서 공용 컴포넌트를 가져다 쓰는 것은 문제가 전혀 없습니다. 그런데 지금 이 컴포넌트를 어디선가 return 해서 사용하기 전까지는 구체적으로 어떤 형태를 띄고 있는지 추측만 할 뿐 확인할 방법이 없습니다.
또한 막상 사용하다가 엣지 케이스에 의해 중간중간 공용 컴포넌트를 수정하는 일도 많아질 수밖에 없습니다. 따라서 Storybook으로 문서화를 하여 시각적으로 확인하고 엣지 케이스도 확인하여 바로바로 적용을 할 수 있는 것입니다.
3. Story를 작성해보자
위에서 스토리북에서의 Story는 컴포넌트의 한 단위라고도 볼 수 있다고 했었는데요. 따라서 UI 컴포넌트의 렌더링된 상태값을 표시하기 위해서는 Story를 작성해서 확인해야겠죠?
Story는 storybook7 버전에서는 .stories.js 나 .stories.ts 파일에 작성을 하게 됩니다.
📜 src/Components/Button.stories.js 파일
(1) default를 작성
import Button from './Button';
export default {
title: 'Components/Button',
component: Button,
tags: ['autodocs']
}
title은 스토리북 메뉴 바에 표시될 스토리북 타이틀을 의미합니다. Storybook 메뉴에 노출되는 제목입니다.
component는 스토리북에 표현될 컴포넌트 자체를 의미하며 해당 컴포넌트가 있는 위치에서 import 하여 매핑해줘야 합니다.
tags는 작성한 스토리를 분류하고 검색을 용이하게 하기 위해 사용되는 키워들을 의미하는데요. 이때 'autodocs'를 작성해 주시면 Storybook에서 Document를 자동으로 작성해주는 기능을 수행할 수 있습니다. 이 Docs를 통해 한 눈에 모든 스토리들을 확인할 수 있습니다.
(2) default에 argTypes를 작성
export default {
title: 'Components/Button',
component: Button,
tags: ['autodocs'], // 자동으로 Document를 작성해주는 기능
/**
* 아래를 설정하지 않으면 모든 간격 및 내부 요소를
* 일일이 설정하여 확인해야 한다.
*/
argTypes: {
variant: {
options: ['primary', 'text', 'default'],
control: { type: 'radio' },
type: 'string',
},
shape: {
options: ['default', 'round'],
control: { type: 'radio' },
type: 'string',
},
size: {
options: ['small', 'medium', 'large'],
control: { type: 'radio' },
type: 'string',
},
onClick: {
action: 'onClick',
},
},
};
argTypes는 컴폰넌트가 가지고 있는 props에 대한 속성값을 정의합니다. props 별로 정의할 수 있는 속성은 다음과 같습니다.
1) control
스토리북에서 컴포넌트 props를 변경할 수 있는 UI 형태를 정의합니다. 유형으로는 "text", "select", "boolean", "array", "radio", "object" 등이 쓰입니다.
select 형태일 때는 options 속성을 추가로 정의해주어야 하는데, 드롭다운 되었을 때 표시되어야 할 type들을 배열 형태로 정의합니다. ex. ["middle", "bottom"]
위와 같은 형태로 스토리북에 표시됩니다.
2) type
props의 타입을 정의합니다. 스토리북에서 기본적으로 제공되는 속성값은 "boolean", "function", "number", "string", "symbol" 타입이 있습니다.
3) required
해당 props가 필수인지 여부를 작성합니다. Boolean 값으로 true, false 중 선택하여 입력하면 됩니다.
4) defaltValue
해당 props에 값이 없을 경우 default로 설정되는 props 값을 정의합니다.
5) description
해당 props의 설명을 작성합니다.
(3) 스토리를 작성
이젠 컴포넌트를 속성 단위 별로 선언해주어 스토리를 작성해봅시다. 우선 기본 기능을 설정해주는 역할을 해주는 Template를 선언합니다.
const Template = args => <Button {...args} />
이제 기본적인 기능을 하는 Template를 선언해 두었으니 이제 여기서 우리가 더 필요한 속성들을 선언해주어 스토리를 만들어봅시다. 반드시 export 해주어야 Storybook에서 확인할 수가 있습니다.
// Template의 기본 기능을 가지고 있음
export const Default = Template.bind({})
Default.args = {
// 추가적인 args 선언
disabled: false,
variant: 'default',
children: 'Button Test',
}
이렇게 작성한 Button.stories.js 파일 완성본은 다음과 같습니다.
import Button from './Button';
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
export default {
title: 'Components/Button',
component: Button,
tags: ['autodocs'], // 자동으로 Document를 작성해주는 기능
/**
* 아래를 설정하지 않으면 모든 간격 및 내부 요소를
* 일일이 설정하여 확인해야 한다.
*/
argTypes: {
variant: {
options: ['primary', 'text', 'default'],
control: { type: 'radio' },
type: 'string',
},
shape: {
options: ['default', 'round'],
control: { type: 'radio' },
type: 'string',
},
size: {
options: ['small', 'medium', 'large'],
control: { type: 'radio' },
type: 'string',
},
onClick: {
action: 'onClick',
},
},
};
// 기본 기능을 설정해주는 역할
const Template = args => <Button {...args} />;
// export할 때마다 Template의 기본 기능을 가져감
export const Default = Template.bind({});
Default.args = {
// 추가적인 args 선언
disabled: false,
variant: 'default',
children: 'Button Test',
};
export const Text = Template.bind({});
Text.args = {
variant: 'text',
children: 'Button Test',
};
export const Primary = Template.bind({});
Primary.args = {
disabled: false,
variant: 'primary',
children: 'Button Test',
};
export const PrimaryRound = Template.bind({});
PrimaryRound.args = {
disabled: false,
variant: 'primary',
shape: 'round',
children: 'Button Test',
};
export const Small = Template.bind({});
Small.args = {
disabled: false,
size: 'small',
children: 'Button Test',
};
export const Medium = Template.bind({});
Medium.args = {
disabled: false,
size: 'medium',
children: 'Button Test',
};
export const Large = Template.bind({});
Large.args = {
disabled: false,
size: 'large',
children: 'Button Test',
};
export const FullWidth = Template.bind({});
FullWidth.args = {
disabled: false,
fullWidth: true,
children: 'Button Test',
};
4. Storybook에서 확인
우선 아래와 같이 왼쪽에서 목록을 한번에 확인할 수 있습니다. 자세히 살펴보면 우리가 다 export 했던 스토리들이죠?
우선 docs에는 아래와 같이 한눈에 확인할 수 있도록 되어 있습니다. 아까 우리가 설정했던 control 대로 잘 되어있습니다.
자세하게 우리가 스토리로 export했던 컴포넌트는 아래와 같이 바로 확인할 수도 있었습니다.
여기까지가 기본적으로 Storybook을 사용하는 방법이였습니다 :) 다음으로는 여러 심화 내용에 대해 알아봅시다....
간단한 컴포넌트
간단한 컴포넌트로 복합적 컴포넌트를 조합해 보려고 합니다. 우선 간단한 컴포넌트부터 하나 만들어보겠습니다.
이번에는 디자인적인 변화들 보다는 상태에 대한 변화를 확인해야 하는 컴포넌트를 만들어볼 건데요.
아래 Task라는 컴포넌트는 현재 어떤 상태에 있는지에 따라 약간씩 다르게 나타납니다. 선택된(또는 선택되지 않은) 체크 박스, task에 대한 정보, 그리고 task를 위 아래로 움직일 수 있는 핀 버튼이 표시될 것입니다. 이를 위해서는 여러 prop들이 필요하겠습니다.
여러 번 말했지만, 위와 같은 여러 상황들을 스토리북을 통해 독립적 환경에서 컴포넌트를 구축할 수 있습니다.
📜 src/Components/Task/Task.js 파일
function Task({task: { id, title, state }, onArchiveTask, onPinTask}) {
return (
<div>
<input type="text" value={title} readOnly={true} />
</div>
)
}
export defatul Task
📜 src/Components/Task/Task.stories.js 파일
import Task from './Task'
export default {
title: 'Components/Task',
component: Task,
tags: ['autodocs'].
}
const Template = (args) => <Task {...args} />
export const Default = Template.bind({})
Default.args = {
task: {
id: '1',
title: 'Test Task',
state: 'TASK_INBOX',
}
}
export const Pinned = Template.bind({})
Pinned.args = {
task: {
...Default.args.task,
state: 'TASK_PINNED',
}
}
export const Archived = Template.bind({})
Archived.args = {
task: {
...Default.args.task,
state: 'TASK_ARCHIVED',
}
}
위에 작성한 코드들은 단순한 프레임만 짠 것이고, 이제 상태(States)를 구현해 보겠습니다.
상태(States) 구현하기
위 코드까지느 아직 기본만 갖춘 상태입니다. 우선 다지인을 이룰 수 있는 코드를 추가로 적어봅시다. 상태에 따라 className을 다르게 주어 다른 스타일을 보여주는 것입니다.
📜 src/Components/Task/Task.js 파일
function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
return (
<div className={`list-item ${state}`}>
<label className="checkbox">
<input
type="checkbox"
defaultChecked={state === 'TASK_ARCHIVED'}
disabled={true}
name="checked"
/>
<span
className="checkbox-custom"
onClick={() => onArchiveTask(id)}
id={`archiveTask-${id}`}
aria-label={`archiveTask-${id}`}
/>
</label>
<div className="title">
<input type="text" value={title} readOnly={true} placeholder="Input title" />
</div>
<div className="actions" onClick={event => event.stopPropagation()}>
{state !== 'TASK_ARCHIVED' && (
<a onClick={() => onPinTask(id)}>
<span className={`icon-star`} id={`pinTask-${id}`} aria-label={`pinTask-${id}`} />
</a>
)}
</div>
</div>
);
export default Task
}
위 코드에서는 task의 state에 따라 다른 className을 가지도록 해서 상태에 따른 디자인을 보여주고 있습니다.
className으로 디자인도 주었기 때문에 preview.js에서 css 파일도 미리 추가했습니다. 그러면 아래와 같이 스토리북이 구성됩니다.
데이터 요구 사항 사항 명시하기
컴포넌트에 필요한 데이터 형태를 명시하려면 리액트에서 propTypes를 사용하는 것이 가장 좋습니다. 이를 자체적 문서화할 뿐만 아니라, 문제를 조기에 발견하는 데 도움이 됩니다.
📜 src/Components/Task/Task.js 파일
import PropTypes from 'prop-types'
function Task({task: {id: title, state}, onArchiveTask, onPinTask}) {
// ...
}
export default Task
Task.propTypes = {
task: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
state: PropTypes.string.isRequired,
}),
onArchiveTask: PropTypes.func,
onPinTask: PropTypes.func,
}
이렇게 미리 작성하게 되면, 잘못 사용되었을 때 경고가 나타날 것입니다.
복잡한 컴포넌트
간단한 컴포넌트로 복합적 컴포넌트를 만들어봅시다. 좀전에 만든 Task 컴포넌트로 TaskList를 만들어보려고 합니다. 컴포넌트를 결합하여 복잡성이 커지는 경우에 대해 자세히 살펴봅시다.
TaskList는 핀으로 고정된 task를 일반 task 위에 배치하여 강조하고는 하죠? 따라서 일반 task와 고정된 task에 대한 두 가지 유형의 TaskList 스토리를 만들어야 합니다.
Task 데이터는 비동기식으로 전송될 수도 있기 때문에, 연결이 없는 상태를 렌더링할 수 있도록 로딩 상태(state)도 필요하겠습니다. 그리고 task가 없는 경우를 위해 비어있는 상태도 필요할 것입니다.
따라서 총 4가지(일반, 고정된, 로딩, task 없을 때)의 스토리가 필요하다고 설계를 하고 시작합니다.
컴포넌트 파일부터 만들어보자
📜 src/Components/TaskList/TaskList.js 파일
import Task from '../Task/Task'
function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
const events = {
onPinTask,
onArchiveTask,
}
const LoadingRow = (
<div className="loading-item">
<span className="glow-checkbox" />
<span className="glow-text">
<span>Loading</span> <span>cool</span> <span>state</span>
</span>
</div>
)
if (loading) {
return (
<div className="list-items" data-testid="loading" key={"loading"}>
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
</div>
)
}
if (tasks.length === 0) {
return (
<div className="list-items" key={"empty"} data-testid="empty">
<div className="wrapper-message">
<span className="icon-check" />
<div className="title-message">일정이 없습니다 🌄</div>
<div className="subtitle-message">편히 쉬세요 !</div>
</div>
</div>
)
}
// PIN 되어있는 task를 먼저 배치하기 위해 정렬
const tasksInOrder = [
...tasks.filter(t => t.state === 'TASK_PINNED'),
...tasks.filter(t => t.state !== "TASK_PINNED"),
]
return (
<div className="list-items">
{tasksInOrder.map(task => (
<Task key={key.id} task={task} {...events} />
))}
</div>
)
}
export default TaskList
스토리 파일도 작성하자
아까 4가지 스토리가 필요하다고 했었습니다.
📜 src/Components/TaskList/TaskList.stories.js 파일
import TaskList from './TaskList'
import * as TaskStories from './Task.stories'
export default {
title: "Components/TaskList",
component: TaskList,
decorators: [story => <div styled={{ padding: '3rem' }}>{story()}</div>], // 모든 스토리에 적용
}
const Template = args => <TaskList {...args} />
export const Default = Template.bind({})
Default.args = {
tasks: [
{...TaskStories.Default.args.task, id: '1', title: 'Task 1'},
{...TaskStories.Default.args.task, id: '2', title: 'Task 2'},
{...TaskStories.Default.args.task, id: '3', title: 'Task 3'},
{...TaskStories.Default.args.task, id: '4', title: 'Task 4'},
{...TaskStories.Default.args.task, id: '5', title: 'Task 5'},
{...TaskStories.Default.args.task, id: '6', title: 'Task 6'},
],
}
export const WithPinnedTasks = Template.bind({})
WithPinnedTasks.args = {
tasks: [
...Default.args.tasks.slice(0, 5),
{ id: '6', title: title: 'Task 6(pinned)', state: 'Task_PINNED' },
],
}
export const Loading = Template.bind({})
Loading.args = {
tasks: [], // 데이터를 아직 받아오지 못한 상황을 가정
loading: true,
}
export const Empty = Template.bind({})
Empty.args = {
tasks: [],
loading: false,
}
데이터 요구사항 및 props 정의
컴포넌트가 커질수록 입력에 필요한 데이터 요구사항도 함께 커지겠죠?
TaskList에서 prop의 요구사항을 정의해봅시다. Task는 하위 컴포넌트이기 때문에 렌더링에 필요한 적합한 형태의 데이터를 제공해야 합니다. 시간 절약을 위해 Task에서 사용한 propTypes를 재사용합니다.
📜 src/Components/TaskList/TaskList.js 파일
import PropTypes from 'prop-types';
import Task from '../Task/Task'
function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
// ...
}
export default TaskList
TaskList.propTypes = {
loading: PropTypes.bool,
tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
onPinTask: PropType.func,
onArchiveTask: PropTypes.func,
};
TaskList.defaultProps = {
loading: false,
}
여기까지 복잡한 컴포넌트를 구성하는 방법까지 알아보았습니다 :)
'프론트엔드 > React' 카테고리의 다른 글
[React] Watch 모드와 테스트가 작동하는 방식 (0) | 2023.06.06 |
---|---|
[React] jest-dom으로 처음 테스트해보기 & 테스팅 라이브러리 구문 소개 (0) | 2023.06.06 |
[React] TDD(Test Driven Development), 자기 주도 개발 - 테스트가 개발을 이끌어 나간다 ✨ (0) | 2023.04.23 |
[React] Jotai👻로 전역 상태 관리를 해보자 (0) | 2023.04.22 |
[React] Hook에 대해 정확히 이해해보자📌 (0) | 2023.04.06 |
- Total
- Today
- Yesterday
- 파이썬
- 프로젝트 회고
- 딥러닝
- 인프런
- 프론트엔드
- 머신러닝
- 프론트엔드 공부
- JSP
- frontend
- CSS
- react
- 리액트
- 데이터분석
- react-query
- 타입스크립트
- 스타일 컴포넌트 styled-components
- 자바스크립트
- rtl
- TypeScript
- styled-components
- Python
- HTML
- next.js
- testing
- 프론트엔드 기초
- 디프만
- 자바스크립트 기초
- 자바
- jest
- 리액트 훅
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |