[DAY-55] React (8)
๐ ํท๊ฐ๋ ธ๋ & ๋ชฐ๋๋ ๋ถ๋ถ๋ค๋ง ์ ๋ฆฌํ๋ ๋๋ง์ TIL
๐ฏ ๋ชจ๋ ๊ฐ์ ๋ด์ฉ์ ์ ์ง ์์์!
์ค๋์ ์๊ฐ์?
๋๋์ด ๋ค์์ฃผ๋ถํฐ ํ๋ก์ ํธ ์์์ด๋ค.
๋จ๋ฆผ ๋ฐ + ๋ฆฌ์ํธ์ ๋ํ ๋ฏธ์ํจ์ผ๋ก ์ธํ ๋๋ ค์ ๋ฐ์ ์ํ๋ก ํ๋ก์ ํธ๋ฅผ ์์ํ๋ค.
์ด์ฌํ ๋ฌ๋ ค๋ณด์๊ณ ~!
[1] ์ฌ์ฉ์ ์ ์ Hook
useAsync
useAsync
๋ํ ์ ๋ฒ ์ค์ต๊ณผ ๋์ผํ๊ฒ useAsyncFn
๊ณผ ๋๋์ด ์์ฑํ๋ค.
// useAsyncFn
import { useState, useCallback, useRef } from "react";
const useAsyncFn = (fn, deps) => {
const lastCallId = useRef(0);
const [state, setState] = useState({
isLoading: false,
});
const callback = useCallback((...args) => {
const callId = ++lastCallId.current;
if (!state.isLoading) {
setState({ ...state, isLoading: true });
}
return fn(...args).then(
(value) => {
callId === lastCallId.current && setState({ value, isLoading: false });
return value;
},
(error) => {
callId === lastCallId.current && setState({ error, isLoading: false });
return error;
}
);
}, deps);
return [state, callback];
};
export default useAsyncFn;
// useAsync
import { useEffect } from "react";
import useAsyncFn from "./usdAsyncFn";
const useAsync = (fn, deps) => {
const [state, callback] = useAsyncFn(fn, deps);
useEffect(() => {
callback();
}, [callback]);
return state;
};
export default useAsync;
// useAsync.stories.js
import useAsync from "../../hooks/useAsync";
export default {
title: "Hook/useAsync",
};
const asyncReturnValue = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve("Success");
}, 1000);
});
};
const asyncReturnError = () => {
return new Promise((_, reject) => {
setTimeout(() => {
reject("Failed");
}, 1000);
});
};
export const Success = () => {
const state = useAsync(async () => {
return await asyncReturnValue();
}, []);
return (
<div>
<div>useAsyncFn Test</div>
<div>{JSON.stringify(state)}</div>
</div>
);
};
export const Error = () => {
const state = useAsync(async () => {
return await asyncReturnError();
}, []);
return (
<div>
<div>useAsyncFn Test</div>
<div>{JSON.stringify(state)}</div>
</div>
);
};
useHotKey
ํด๋น ์ค์ต์ด ์ ์ผ ์ด๋ ค์ ๋ค.
์ฌ๋ฌ ํค ์กฐํฉ์ ํ์ด ์ ์ฅํ๋ ๊ณผ์ ์ ์ดํดํ๋๋ฐ ์๊ฐ์ด ๋ง์ด ๊ฑธ๋ ธ๋ ๊ฒ ๊ฐ๋ค.
// useHotKey.js
import { useCallback, useEffect, useMemo } from "react";
const ModifierBitMasks = {
alt: 1,
ctrl: 2,
meta: 4,
shift: 8,
};
const ShiftKeys = {
"~": "`",
"!": "1",
"@": "2",
"#": "3",
$: "4",
"%": "5",
"^": "6",
"&": "7",
"*": "8",
"(": "9",
")": "0",
_: "-",
"+": "=",
"{": "[",
"}": "]",
"|": "\\",
":": ";",
'"': "'",
"<": ",",
">": ".",
"?": "/",
};
const Aliases = {
win: "meta",
window: "meta",
cmd: "meta",
command: "meta",
esc: "escape",
opt: "alt",
option: "alt",
};
const getKeyCombo = (e) => {
const key = e.key !== " " ? e.key.toLowerCase() : "space";
let modifiers = 0;
if (e.altKey) modifiers += ModifierBitMasks.alt;
if (e.ctrlKey) modifiers += ModifierBitMasks.ctrl;
if (e.metaKey) modifiers += ModifierBitMasks.meta;
if (e.shiftKey) modifiers += ModifierBitMasks.shift;
return { modifiers, key };
};
const parseKeyCombo = (combo) => {
const pieces = combo.replace(/\s/g, "").toLowerCase().split("+");
let modifiers = 0;
let key;
for (const piece of pieces) {
if (ModifierBitMasks[piece]) {
modifiers += ModifierBitMasks[piece];
} else if (ShiftKeys[piece]) {
modifiers += ModifierBitMasks.shift;
key = ShiftKeys[piece];
} else if (Aliases[piece]) {
key = Aliases[piece];
} else {
key = piece;
}
}
return { modifiers, key };
};
const comboMatches = (a, b) => {
return a.modifiers === b.modifiers && a.key === b.key;
};
const useHotKey = (hotkeys) => {
const localKeys = useMemo(() => hotkeys.filter((k) => !k.global), [hotkeys]);
const globalKeys = useMemo(() => hotkeys.filter((k) => k.global), [hotkeys]);
const invokeCallback = useCallback(
(global, combo, callbackName, e) => {
for (const hotkey of global ? globalKeys : localKeys) {
if (comboMatches(parseKeyCombo(hotkey.combo), combo)) {
hotkey[callbackName] && hotkey[callbackName](e);
}
}
},
[localKeys, globalKeys]
);
const handleGlobalKeyDown = useCallback(
(e) => {
invokeCallback(true, getKeyCombo(e), "onKeyDown", e);
},
[invokeCallback]
);
const handleGlobalKeyUp = useCallback(
(e) => {
invokeCallback(true, getKeyCombo(e), "onKeyUp", e);
},
[invokeCallback]
);
const handleLocalKeyDown = useCallback(
(e) => {
invokeCallback(
false,
getKeyCombo(e.nativeEvent),
"onKeyDown",
e.nativeEvent
);
},
[invokeCallback]
);
const handleLocalKeyUp = useCallback(
(e) => {
invokeCallback(
false,
getKeyCombo(e.nativeEvent),
"onKeyUp",
e.nativeEvent
);
},
[invokeCallback]
);
useEffect(() => {
document.addEventListener("keydown", handleGlobalKeyDown);
document.addEventListener("keyup", handleGlobalKeyUp);
return () => {
document.removeEventListener("keydown", handleGlobalKeyDown);
document.removeEventListener("keyup", handleGlobalKeyUp);
};
}, [handleGlobalKeyDown, handleGlobalKeyUp]);
return { handleKeyDown: handleLocalKeyDown, handleKeyUp: handleLocalKeyUp };
};
export default useHotKey;
// useHotKey.stories.js
import useHotKey from "../../hooks/useHotKey";
import { useState } from "react";
export default {
title: "Hook/useHotKey",
};
export const Default = () => {
const [value, setValue] = useState("");
const hotkeys = [
{
global: true,
combo: "meta+shift+k",
onKeyDown: (e) => {
alert("meta+shift+k");
},
},
{
combo: "esc",
onKeyDown: (e) => {
setValue("");
},
},
];
const { handleKeyDown } = useHotKey(hotkeys);
return (
<div>
<div>useHotKey ํ
์คํธ</div>
<input
onKeyDown={handleKeyDown}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
);
};
[2] ์ปดํฌ๋ํธ ์ค์ต
Modal
// Modal.js
import styled from "@emotion/styled";
import { useMemo, useEffect } from "react";
import ReactDOM from "react-dom";
import useClickAway from "../../../hooks/useClickAway";
const BackgroundDim = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
`;
const ModalContainer = styled.div`
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 8px;
background-color: white;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
`;
const Modal = ({
children,
width = 500,
height,
visible = false,
onClose,
...props
}) => {
const ref = useClickAway(() => {
onClose && onClose();
});
const containerStyle = useMemo(
() => ({
width,
height,
}),
[width, height]
);
const el = useMemo(() => document.createElement("div"), []);
useEffect(() => {
document.body.appendChild(el);
return () => {
document.body.removeChild(el);
};
});
return ReactDOM.createPortal(
<BackgroundDim style={{ display: visible ? "block" : "none" }}>
<ModalContainer
{...props}
style={{ ...props.style, ...containerStyle }}
ref={ref}
>
{children}
</ModalContainer>
</BackgroundDim>,
el
);
};
export default Modal;
// Modal.stories.js
import Modal from "../../components/_BasicComponent/Modal";
import { useState } from "react";
export default {
title: "Component/Modal",
component: Modal,
};
export const Default = () => {
const [visible, setVisible] = useState(false);
return (
<div>
<button onClick={() => setVisible(true)}>Show Modal!</button>
<Modal visible={visible} onClose={() => setVisible(false)}>
Hi
</Modal>
</div>
);
};
Toast
Toast
์ปดํฌ๋ํธ๋ Toast ์์ดํ
๋ค์ ์ ์ฅํ๋ ToastItem
, ๋์์ ๊ด๋ฆฌํ๋ ToastManager
๋ก ๋๋์ด ์์ฑํ ์ ์๋ค.
// ToastItem.js
import Text from "../Text";
import useTimeout from "../../../hooks/useTimeout";
import styled from "@emotion/styled";
import { useState } from "react";
const Container = styled.div`
position: relative;
display: flex;
width: 450px;
height: 70px;
padding: 0 20px;
align-items: center;
border-radius: 4px;
border-top-left-radius: 0;
border-top-right-radius: 0;
border: 1px solid #ccc;
background-color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
opacity: 1;
transition: opacity 0.4s ease-out;
&:first-of-type {
animation: move 0.4s ease-out forwards;
}
&:not(:first-of-type) {
margin-top: 8px;
}
@keyframes move {
0% {
margin-top: 80px;
}
100% {
margin-top: 0;
}
}
`;
const ProgressBar = styled.div`
position: absolute;
top: 0;
left: 0;
width: 0;
height: 4px;
background-color: #44b;
animation-name: progress;
animation-timing-function: linear;
animation-fill-mode: forwards;
@keyframes progress {
0% {
width: 0;
}
100% {
width: 100%;
}
}
`;
const ToastItem = ({ id, message, duration, onDone }) => {
const [show, setShow] = useState(true);
useTimeout(() => {
setShow(false);
setTimeout(() => onDone(), 400);
}, duration);
return (
<Container style={{ opacity: show ? 1 : 0 }}>
<ProgressBar style={{ animationDuration: `${duration}ms` }} />
<Text>{message}</Text>
</Container>
);
};
export default ToastItem;
// ToastManager.js
import styled from "@emotion/styled";
import { useCallback, useState, useEffect } from "react";
import { v4 } from "uuid";
import ToastItem from "./ToastItem";
const Container = styled.div`
position: fixed;
top: 16px;
right: 16px;
z-index: 1500;
`;
const ToastManager = ({ bind }) => {
const [toasts, setToasts] = useState([]);
const createToast = useCallback((message, duration) => {
const newToast = {
id: v4(),
message,
duration,
};
setToasts((oldToasts) => [...oldToasts, newToast]);
}, []);
const removeToast = useCallback((id) => {
setToasts((oldToasts) => oldToasts.filter((toast) => toast.id !== id));
}, []);
useEffect(() => {
bind(createToast);
}, [bind, createToast]);
return (
<Container>
{toasts.map(({ id, message, duration }) => (
<ToastItem
message={message}
duration={duration}
key={id}
onDone={() => removeToast(id)}
/>
))}
</Container>
);
};
export default ToastManager;
// index.js
import ReactDOM from "react-dom";
import ToastManager from "./ToastManager";
// ํ ์คํธ ๊ด๋ฆฌ ๋ฐ ํ ์คํธ ๋์ฐ๋ ์ญํ Class
class Toast {
portal = null;
constructor() {
const portalId = "toast-portal";
const portalElement = document.getElementById(portalId);
if (portalElement) {
this.portal = portalElement;
return;
} else {
this.portal = document.createElement("div");
this.portal.id = portalId;
document.body.appendChild(this.portal);
}
ReactDOM.render(
<ToastManager
bind={(createToast) => {
this.createToast = createToast;
}}
/>,
this.portal
);
}
show(message, duration = 2000) {
this.createToast(message, duration);
}
}
export default new Toast();
// Toast.stories.js
import Toast from "../../components/_BasicComponent/Toast";
export default {
title: "Component/Toast",
};
export const Default = () => {
return <button onClick={() => Toast.show("Hello!", 3000)}>Show Toast</button>;
};
[3] ๊ทธ๋ฆผํ ์ค์ต
canvas๋ฅผ ์ฌ์ฉํด Paint
์ปดํฌ๋ํธ๋ฅผ ์ ์ํ๊ณ Plugins๋ฅผ ์์ฑํ๋ค.
ํ์ฅ์ฑ ์๊ฒ ์ฌ์ฉํ ์ ์์ ๊ฒ ๊ฐ๋ค.
๋์ค์ ์ง์ง ๊ทธ๋ฆผํ์ฒ๋ผ ๋ค๋ฅธ ํ๋ค๋ ์ถ๊ฐํด๋ด์ผ์ง.
๋๊ธ๋จ๊ธฐ๊ธฐ