[react] 커스텀 훅 연습 2

young-gue Park·2023년 3월 23일
0

React

목록 보기
13/17
post-thumbnail

⚡ 커스텀 훅 연습 2


📌 useForm

  • 입력창 사용을 위한 훅
  • formik이라는 더 좋은 라이브러리가 있긴 하다.

    🤷‍♂️ formik은 추후에 알아보자...

💻 useForm.js

import { useState } from "react";

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,
    };
};

export default useForm;

💻 useForm.stories.js

import useForm from "../../hooks/useForm";


export default {
    title: 'Hook/useForm',
};

const sleep = () => {
    return new Promise((resolve) => {
        setTimeout(() => resolve(), 1000)
    });
};

export const Default = () => {
    const { isLoading, errors, handleChange, handleSubmit } = useForm({
        initialValues: {
            email: '',
            password: ''
        },
        onSubmit: async (values) => {
            await sleep();
            alert(JSON.stringify(values))
        },
        validate: ({ email, password }) => {
            const errors = {};
            if(!email) errors.email = "이메일을 입력해주세요.";
            if(!password) errors.password = "비밀번호를 입력해주세요.";
            if(!/^.+@.+\..+$/.test(email)) errors.email = "올바른 이메일을 입력해주세요.";
            return errors;
        }
    });
    return (
        <form onSubmit={handleSubmit}>
            <h1>Sign In</h1>
            <div>
                <input name="email" type="email" placeholder="Email" onChange={handleChange}/>
                {errors.email}
            </div>
            <div>
                <input name="password" type="password" placeholder="Password" onChange={handleChange}/>
                {errors.password}
            </div>
            <button type="submit" disabled={isLoading}>{isLoading ? 'Loading...' : 'Submit'}</button>
        </form>
    )
}

🖨 완성된 훅 시연

이벤트 발생 전

올바른 이메일 형식이 아닐 때, 비밀번호를 입력하지 않았을 때

📌 useTimeOutFn, useTimeout

  • 시간이 지난 후 리디렉션 되는 등의 이벤트를 담당하는 훅
  • useTimeOutFn은 직접 실행 이벤트 지시가 가능, useTimeOut은 이벤트가 자동 실행

💻 useTimeOutFn.js

import { useCallback, useEffect, useRef } from "react";

// 함수 호출을 통한 useTimeOut
const useTimeOutFn = (fn, ms) => {
    const timeoutId = useRef();
    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);
    }, [])

    return [run, clear];
};

export default useTimeOutFn;

💻 useTimeOutFn.stories.js

import useTimeOutFn from "../../hooks/useTimeOutFn";


export default {
    title: 'Hook/useTimeoutFn',
}

export const Default = () => {
    const [run, clear] = useTimeOutFn(() => {
        alert('launch');
    }, 3000);

    return (
        <>
            <div>useTimeOutFn 테스트</div>
            <button onClick={run}>3초 뒤 실행</button>
            <button onClick={clear}>정지</button>
        </>
    )
}

🖨 완성된 훅 시연

실행 버튼을 누르고 정지 버튼을 누르지 않았을 때

실행 버튼을 누르고 3초 안에 정지 버튼을 누르면 이벤트가 발생하지 않는다.

💻 useTimeout.js

import { useEffect } from "react";
import useTimeOutFn from "./useTimeOutFn"

// useTimeOutFn 컴포넌트를 이용한 useTimeout
const useTimeout = (fn, ms) => {
    const [run, clear] = useTimeOutFn(fn, ms);

    useEffect(() => {
        run();
        return clear;
    }, [run, clear])

    return clear;
};

export default useTimeout;

💻 useTimeout.stories.js

import useTimeout from "../../hooks/useTimeout";

export default {
    title: 'Hook/useTimeout',
}

export const Default = () => {
    const clear = useTimeout(() => {
        alert('launch');
    }, 3000);

    return (
        <>
            <div>useTimeOut 테스트</div>
            <button onClick={clear}>정지</button>
        </>
    )
}

🖨 완성된 훅 시연

3초 안에 정지 버튼을 누르지 않으면 이벤트가 발생한다.

📌 useIntervalFn, useInterval

  • 웹 사이트에서 반복되는 작업을 담당하는 훅
  • useTimeout과 마찬가지로 Fn은 직접 실행 이벤트 지시가 가능, useInterval은 이벤트가 자동 실행

💻 useIntervalFn.js

import { useCallback, useEffect, useRef } from "react"

const useIntervalFn = (fn, ms) => {
    const intervalId = useRef();
    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];
}

export default useIntervalFn;

💻 useIntervalFn.stories.js

import { useState } from "react";
import useIntervalFn from "../../hooks/useIntervalFn";


export default {
    title: 'Hook/useIntervalFn',
}

export const Default = () => {
    const [array, setArray] = useState([]);
    const [run, clear] = useIntervalFn(() => {
        setArray([...array, '추가됨!']);
    }, 1000);

    return (
        <>
            <div>useIntervalFn 테스트</div>
            <div>{array}</div>
            <button onClick={run}>1초 마다 추가</button>
            <button onClick={clear}>정지</button>
        </>
    )
}

🖨 완성된 훅 시연

실행 버튼을 누르면 1초에 한 개씩 추가되지만 정지 버튼을 누르면 추가가 멈춘다.

💻 useInterval.js

import { useEffect } from "react";
import useIntervalFn from "./useIntervalFn";

const useInterval = (fn, ms) => {
    const [run, clear] = useIntervalFn(fn, ms);

    useEffect(() => {
        run();
        return clear;
    }, [run, clear]);

    return clear;
};

export default useInterval;

💻 useInterval.stories.js

import { useState } from "react";
import useInterval from "../../hooks/useInterval";


export default {
    title: 'Hook/useInterval',
}

export const Default = () => {
    const [array, setArray] = useState([]);
    const clear = useInterval(() => {
        setArray([...array, '추가됨!']);
    }, 1000);

    return (
        <>
            <div>useInterval 테스트</div>
            <div>{array}</div>
            <button onClick={clear}>정지</button>
        </>
    )
}

🖨 완성된 훅 시연

1초에 한 개씩 추가되다가 정지 버튼을 누르면 추가가 멈춘다.

📌 useDebounce

  • 특정 기간 내에 같은 이벤트가 호출 됐을 경우 가장 마지막 이벤트만 호출하는 훅
  • useTimeOutFn을 활용한다.

💻 useDebounce.js

import { useEffect } from "react";
import useTimeOutFn from "./useTimeOutFn";

const useDebounce = (fn, ms, deps) => {
    const [run, clear] = useTimeOutFn(fn, ms);

    // eslint-disable-next-line
    useEffect(run, deps);

    return clear;
};

export default useDebounce;

💻 useDebounce.stories.js

import { Fragment, useState } from "react";
import useDebounce from "../../hooks/useDebounce";

export default {
    title: 'Hook/useDebounce',
}

const companies = [
    'kakao',
    'Naver',
    'Daangn',
    'Coupang',
    'Line'
]

export const Default = () => {
    const [value, setValue] =useState('');
    const [result, setResult] = useState([]);

    useDebounce(() => {
        if(value === '') setResult([]);
        else {
                setResult(
                companies.filter((company) => 
                    company.toLowerCase().includes(value.toLowerCase())
                    )
                );
        }
    }, 300, [value]);

    return (
        <div>
            <h1>대기업 검색 해보슈</h1>
            <input type="text" value={value} onChange={(e) => setValue(e.target.value)} />
            <div>
                {result.map((item) => (
                    <Fragment key={item}>
                        {item}
                        <br />
                    </Fragment>
                ))}
            </div>
        </div>
    )
};

🖨 완성된 훅 시연

입력된 글자가 포함되는 기업 이름을 노출시킨다.

📌 useAsyncFn, useAsync

  • 비동기 로직을 제거하기 위해 사용하는 훅
  • 네트워크, 타임아웃 등의 로직이 있을 때 사용한다.
  • 함수 호출로 실행하는 useAsyncFn 훅, 컴포넌트가 로드되면 바로 실행되는 useAsync 훅으로 나뉜다.

💻 useAsyncFn.js

import { useCallback, useRef, useState } 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;

💻 useAsyncFn.stories.js

import useAsyncFn from "../../hooks/useAsyncFn";


export default {
    title: 'Hook/useAsyncFn',
}

const asyncReturnValue = () => {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Success');
        }, 1000);
    })
};

const asyncReturnError = () => {
    return new Promise((_, reject) => {
        setTimeout(() => {
            reject('Error');
        }, 1000);
    })
};

export const Success = () => {
    const [state, callback] = useAsyncFn(async () => {
        return await asyncReturnValue();
    }, []);

    return (
        <div>
            <div>useAsyncFn 테스트</div>
            <div>{JSON.stringify(state)}</div>
            <button onClick={callback} disabled={state.isLoading}>비동기 호출</button>
        </div>
    )
}

export const Error = () => {
    const [state, callback] = useAsyncFn(async () => {
        return await asyncReturnError();
    }, []);

    return (
        <div>
            <div>useAsyncFn 테스트</div>
            <div>{JSON.stringify(state)}</div>
            <button onClick={callback} disabled={state.isLoading}>비동기 호출</button>
        </div>
    )
}

🖨 완성된 훅 시연

이벤트 발생 전

버튼을 누르면 isLoading의 값이 true로 변함을 알 수 있다.

이벤트가 종료되고 resolve된 value 값이 성공으로 바뀌면서 isLoading이 다시 false로 변하였다.

error가 뜨면 reject된 error 값이 들어온다.

💻 useAsync.js

import { useEffect } from "react";
import useAsyncFn from "./useAsyncFn";

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('Error');
        }, 1000);
    })
};

export const Success = () => {
    const state = useAsync(async () => {
        return await asyncReturnValue();
    }, []);

    return (
        <div>
            <div>useAsync 테스트</div>
            <div>{JSON.stringify(state)}</div>
        </div>
    )
}

export const Error = () => {
    const state = useAsync(async () => {
        return await asyncReturnError();
    }, []);

    return (
        <div>
            <div>useAsync 테스트</div>
            <div>{JSON.stringify(state)}</div>
        </div>
    )
}

🖨 완성된 훅 시연

useAsyncFn과 이벤트 순서와 결과는 같지만 이벤트가 자동 실행된다는 점만 다르다.

📌 useHotKey

  • 복잡한 단축키를 지원하기 위해 만들어진 훅 (ex: ctrl + R 등)

💻 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';

    console.log(key);

    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) {
            // TODO: 단축키 처리를 한다.
            // callbackName: onKeyDown, onKeyUp
            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 { useState } from "react";
import useHotKey from "../../hooks/useHotKey";


export default {
    title: 'Hook/useHotKey',
}

export const Default = () => {
    const [value, setValue] = useState('');

    const hotkeys = [
        {
            global: true,
            combo: 'meta',
            onKeyDown: (e) => {
                alert('meta')
            }
        },
        {
            global: true,
            combo: 'alt+w',
            onKeyDown: (e) => {
                alert('alt+w')
            }
        }, 
        {
            global: true,
            combo: 'ctrl+shift+k',
            onKeyDown: (e) => {
                alert('ctrl+shift+k')
            }
        }, 
        {
            combo: 'esc',
            onKeyDown: (e) => {
                setValue("");
            }
        }, 
    ];

    const { handleKeyDown } = useHotKey(hotkeys);

    return (
        <>
            <h1>useHotKey 테스트</h1>
            <h2>1. meta</h2>
            <h2>2. alt + w</h2>
            <h2>3. ctrl + shift + k</h2>

            <h1>로컬 테스트</h1>
            <h2>esc를 눌러 입력창 지우기</h2>
            <input onKeyDown={handleKeyDown} value={value} onChange={(e) => setValue(e.target.value)} />
        </>
    )
}

🖨 완성된 훅 시연

이벤트 발생 전

ctrl + shift + k 를 눌렀을 때

그리고 esc를 누르면 입력창의 텍스트가 지워진다.

이것으로 일단 커스텀 훅 제작을 마무리했다.
추후에 더 만들만한 훅이 있으면 조금씩 더 추가할 예정이다.

profile
Hodie mihi, Cras tibi

0개의 댓글