formik
이라는 라이브러리가 훌륭해서 잘 쓰일일이 없다고 한다.
무슨 라이브러리인고 찾아보니, contextAPI
기능도 같이 들어있는 폼 관리 라이브러리다.
최소한의 필요한 기능만 들어있어서 사이즈도 작고 필요한 컴포넌트에서 꺼내쓸 수 있는 contextAPI
가 있어서 좋은 것 같다.
//useForm.js
const useForm = ({ initialValues, onSubmit, validate }) => {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setValues({ ...values, [name]: value });
};
const handleSubmit = async (e) => {
setIsLoading(true);
e.preventDefault();
const newErrors = validate ? validate(values) : {};
if (Object.keys(newErrors).length === 0) {
await onSubmit(values);
}
setErrors(newErrors);
setIsLoading(false);
};
return { values, errors, isLoading, handleChange, handleSubmit };
};
//사용하는곳
const { isLoading, errors, handleChange, handleSubmit } = useForm({
initialValues: {
email: "",
password: "",
},
onSubmit: async (values) => {
alert(JSON.stringify(values));
},
validate: ({ email, password }) => {
const errors = {};
if (!email) errors.email = "이메일을 입력해주세요";
if (!password) errors.password = "비밀번호를 입력해주세요";
return errors;
},
});
이런식으로 사용하는 곳에서도 에러처리를 용이하게 할수있도록 만드셨다.
함수 호출을 통한 방법과 컴포넌트가 로딩된 후 호출되는 방법 2가지를 만들어본다.
//useTimeoutFn.js
const useTimeoutFn = (fn, ms) => {
const timeoutId = useRef(null);
const callback = useRef(fn);
useEffect(() => {
callback.current = fn;
}, [fn]);
const run = useCallback(() => {
timeoutId.current && clearTimeout(timeoutId.current);
timeoutId.current = setTimeout(() => {
callback.current();
}, ms);
}, [ms]);
const clear = useCallback(() => {
timeoutId.current && clearTimeout(timeoutId.current);
}, []);
useEffect(() => clear, [clear]);
return [run, clear];
};
//useTimeout.js
const useTimeout = (fn, ms) => {
const [run, clear] = useTimeoutFn(fn, ms);
useEffect(() => {
run();
return clear;
}, [clear, run]);
return clear;
};
한번 더 래핑해서 사용한다. 왜?
=> 컴포넌트가 로딩된 후에 작업하려면 useTimeout
내부의 useEffect
처럼 반복로직이 또 존재함. 따라서 한번 더 래핑해서 처리한다.
useTimeout하고 거의 똑같을수밖에 없음.
//useIntervalFn.js
const useIntervalFn = (fn, ms) => {
const intervalId = useRef(null);
const callback = useRef(fn);
useEffect(() => {
callback.current = fn;
}, [fn]);
const run = useCallback(() => {
intervalId.current && clearInterval(intervalId.current);
intervalId.current = setInterval(() => {
callback.current();
}, ms);
}, [ms]);
const clear = useCallback(() => {
intervalId.current && clearInterval(intervalId.current);
}, []);
useEffect(() => clear, [clear]);
return [run, clear];
};
//useInterval.js
const useInterval = (fn, ms) => {
const [run, clear] = useIntervalFn(fn, ms);
useEffect(() => {
run();
return clear;
}, [clear, run]);
return clear;
};
사실상 이름만 바뀐거라고 보면 된다. 다만 여기서 fn
의존성을 분리한건 굉장히 중요포인트임
=> 분리하지 않았다면 콜백함수가 바뀌었을시 처음부터 다시 인터벌을 시작하게 될수 있다.
디바운스는 특정 시간 내 같은 이벤트 호출시 마지막 호출 이벤트만 실행되게 하는 기법이다. 노션클론 과제때 setTimeout
을 이용하여 적용했던 기억이 난다.
const useDebounce = (fn, ms, deps) => {
const [run, clear] = useTimeoutFn(fn, ms);
useEffect(run, [...deps, run]);
return clear;
};
진짜 간단하다!
강의에서는 의존성을 매개변수에서 가져와 넣지 말라며 오류가 나던데, 나는 그런 오류가 아니라 run
을 안넣었다고 오류가 났다. 그래서 넣어줌.
비동기로직을 제거하기 위한 훅. 이 역시 작동하는 함수와 컴포넌트에서 쓰는 훅으로 나눠둠.
//useAsyncFn.js
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;
}
);
//eslint-disable-next-line
}, deps);
return [state, callback];
};
//useAsync.js
const useAsync = (fn, deps) => {
const [state, callback] = useAsyncFn(fn, deps);
useEffect(() => {
callback();
}, [callback]);
return state;
};
lastCallId
를 사용한 이유는 여러 콜백이 들어왔을때 맨 마지막으로 들어온 콜백의 값을 저장하기 위함이다.
의문점은 왜 async-await
대신 then
을 쓰신걸까? 제일 궁금하다. 이따 슬랙으로 질문드려봐야겠다!
무진장 길다...차근차근 살펴보자
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) => a.modifiers === b.modifiers && a.key === b.key;
const useHotKey = (hotkeys) => {
const localKeys = useMemo(
() => hotkeys.filter((hotkey) => !hotkey.global),
[hotkeys]
);
const globalKeys = useMemo(
() => hotkeys.filter((hotkey) => hotkey.global),
[hotkeys]
);
const invokeCallback = useCallback(
(global, combo, callbackName, e) => {
for (const hotkey of global ? globalKeys : localKeys) {
if (comboMatches(parseKeyCombo(hotkey.combo), combo)) {
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 };
};
//사용할땐 이렇게
const hotKeys = [
{
global: true,
combo: "ctrl+shift+k",
onKeyDown: (e) => {
alert("meta+k");
},
},
{
combo: "esc",
onKeyDown: (e) => {
alert("esc");
},
},
];
const { handleKeyDown } = useHotKey(hotKeys);
return (
<div>
핫키 테스트
<input onKeyDown={handleKeyDown} />
</div>
);
자 차근차근 살펴보자. 사실 여러 함수로 쪼개놔서 그렇지 막상 따라가보면 그리 어렵지않다는 걸 알수있음.
hotkeys
라는매개변수를 받는다. 이때 매개변수 내부는{
global: 전역에서 감지할 것인지, 특정 노드에서 감지할 것인지,
combo:{키 조합},
callback:이벤트 핸들러,
e:이벤트 객체
}
이렇게 네가지 매개변수를 받는다.useMemo
를 사용하여 감지할 키가 바뀌지 않았으면 값을 그대로 사용.invokecallback
으로 콜백을 한번 래핑해준다. 이때 useCallback
을 사용하여 감지할 키 값들이 변경되지 않았다면 함수를 그대로 사용한다. 또한 2번에서 나눠준 키를 순회하며 만약 누른 키가 키 조합에 있다면 콜백 함수를 실행한다combomatches
함수는 parseKeyCombo(hotkey.combo)
와 매개변수로 받아온 combo
를 비교하여 불리언을 리턴한다.parseKeyCombo
함수는 받아온 핫키(전역or지역)의 combo
를 받아와서 공백을 제거한 뒤 +
를 기준으로 자름.Shift
키와 누른 일반키가 존재할 수 있으므로(특수문자 등) 이 또한 ShiftKeys
객체를 기반으로 예외처리를 해준다.combo
는 getKeyCombo
함수를 거쳐온다. 공백예외처리도 해주고 특수조합키(ctrl, alt, meta, shift)를 비트마스크(1,2,4,8)처럼 활용한다.modifier
가 겹칠수 있지만, 핫키는 같은키 두번 연속을 지원하지 않는다.짜잔 끝!
handleLocalKeyDown, Up
핸들러에 들어간 e.nativeEvnet
는 JS의 이벤트와 똑같이 만들어놓은 리액트의 이벤트 객체다.
리액트는 원래 JS이벤트 객체를 래핑한 리액트만의 이벤트 객체를 사용함. 이때 특정 이벤트는 리액트 이벤트에 매핑되지 않아서 e.nativeEvent
를 사용한다.
위가 래핑된 이벤트. 아래가 원래 브라우저 이벤트 객체
사실keyDown
이벤트는 래핑된 이벤트에서 잘 작동한다.
왜 이걸 사용하신걸깜?
useHotKey
부분이 함수가 쪼개져서 약간 어려웠는데, 차근차근 따라가다보니 잘 이해할수 있었다. 다만 커스텀 훅을 여러 함수로 쪼개 뚝딱 만들수 있냐는건 별개의 얘기. 그래서 연습이 필요한거다.
2월쯤 커리큘럼에 함수형 프로그래밍이있긴한데, 예습을 얼른 해야할것같다...조만간 과제도 있는데, 어떤 방식으로 공부할 지고민이다!