From 78bf217d7501c601415a95adcaf71edb81a2b436 Mon Sep 17 00:00:00 2001 From: optrader Date: Thu, 20 Nov 2025 06:05:41 +0900 Subject: [PATCH] [251120] test: hooks - usePrevious.js, usePrevious.test.js, usePreviou... MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ• 컀밋 μ‹œκ°„: 2025. 11. 20. 06:05:41 πŸ“Š λ³€κ²½ 톡계: β€’ 총 파일: 3개 β€’ μΆ”κ°€: +20쀄 β€’ μ‚­μ œ: -7쀄 πŸ“ μΆ”κ°€λœ 파일: + com.twin.app.shoptime/src/hooks/usePrevious.test.js + com.twin.app.shoptime/src/hooks/usePreviousExample.jsx πŸ“ μˆ˜μ •λœ 파일: ~ com.twin.app.shoptime/src/hooks/usePrevious.js πŸ”§ ν•¨μˆ˜ λ³€κ²½ λ‚΄μš©: πŸ“„ com.twin.app.shoptime/src/hooks/usePrevious.js (javascript): ❌ Deleted: usePrevious() πŸ“„ com.twin.app.shoptime/src/hooks/usePreviousExample.jsx (javascript): βœ… Added: handleUpdate() πŸ”§ μ£Όμš” λ³€κ²½ λ‚΄μš©: β€’ 핡심 λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 κ°œμ„  β€’ ν…ŒμŠ€νŠΈ 컀버리지 및 μ•ˆμ •μ„± ν–₯상 --- .../src/hooks/usePrevious.js | 31 +- .../src/hooks/usePrevious.test.js | 309 ++++++++++++++++++ .../src/hooks/usePreviousExample.jsx | 242 ++++++++++++++ 3 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 com.twin.app.shoptime/src/hooks/usePrevious.test.js create mode 100644 com.twin.app.shoptime/src/hooks/usePreviousExample.jsx diff --git a/com.twin.app.shoptime/src/hooks/usePrevious.js b/com.twin.app.shoptime/src/hooks/usePrevious.js index cd5b8173..118110b3 100644 --- a/com.twin.app.shoptime/src/hooks/usePrevious.js +++ b/com.twin.app.shoptime/src/hooks/usePrevious.js @@ -1,13 +1,26 @@ -import {useRef, useEffect} from 'react'; +import { useRef, useEffect } from 'react'; -function usePrevious (value) { - const ref = useRef(); +/** + * usePrevious - React 16.7 μ „μš© + * @param {*} value – ν˜„μž¬κ°’ (λ°°μ—΄, 객체, ν”„λ‘­ λ“±) + * @param {*} [initial] – 졜초 λ Œλ”μ‹œ λ°˜ν™˜ν•  κ°’ (선택사항, default: undefined) + * @return {*} – 이전 λ Œλ”μ—μ„œμ˜ κ°’ + * + * μ‚¬μš© μ˜ˆμ‹œ + * const prev = usePrevious(value); + * if (prev !== value) { … } + * + * // μ΄ˆκΈ°κ°’ μ§€μ • + * const prev = usePrevious(count, 0); + */ +export default function usePrevious(value, initial) { + // useRef κ°€ μ €μž₯ν•œ κ°μ²΄λŠ” λ Œλ” μ‚¬μ΄μ—μ„œλ„ λ™μΌν•©λ‹ˆλ‹€. + const ref = useRef(initial); - useEffect(() => { - ref.current = value; - }, [value]); + // value κ°€ λ°”λ€” λ•Œλ§ˆλ‹€ ref λ₯Ό κ°±μ‹  + useEffect(() => { + ref.current = value; + }, [value]); // value 의 λ³€κ²½λ§Œ 감지 - return ref; + return ref.current; } - -export default usePrevious; diff --git a/com.twin.app.shoptime/src/hooks/usePrevious.test.js b/com.twin.app.shoptime/src/hooks/usePrevious.test.js new file mode 100644 index 00000000..1a95e606 --- /dev/null +++ b/com.twin.app.shoptime/src/hooks/usePrevious.test.js @@ -0,0 +1,309 @@ +import { renderHook } from '@testing-library/react'; +import usePrevious from './usePrevious'; + +describe('usePrevious', () => { + // 단일 κ°’ ν…ŒμŠ€νŠΈ + describe('단일 κ°’ 좔적', () => { + it('μ΄ˆκΈ°κ°’ 없이 ν˜ΈμΆœν•˜λ©΄ 초기 λ Œλ”λ§μ—μ„œ undefinedλ₯Ό λ°˜ν™˜ν•΄μ•Ό ν•œλ‹€', () => { + const { result } = renderHook(() => usePrevious(0)); + expect(result.current).toBeUndefined(); + }); + + it('μ΄ˆκΈ°κ°’μ΄ μ§€μ •λ˜λ©΄ 초기 λ Œλ”λ§μ—μ„œ μ΄ˆκΈ°κ°’μ„ λ°˜ν™˜ν•΄μ•Ό ν•œλ‹€', () => { + const { result } = renderHook(() => usePrevious(0, -1)); + expect(result.current).toBe(-1); + }); + + it('값이 λ³€κ²½λ˜λ©΄ 이전 값을 λ°˜ν™˜ν•΄μ•Ό ν•œλ‹€', () => { + const { result, rerender } = renderHook(({ value }) => usePrevious(value), { + initialProps: { value: 0 }, + }); + + expect(result.current).toBeUndefined(); + + rerender({ value: 1 }); + expect(result.current).toBe(0); + + rerender({ value: 2 }); + expect(result.current).toBe(1); + + rerender({ value: 3 }); + expect(result.current).toBe(2); + }); + + it('숫자 값을 μ •ν™•νžˆ 좔적해야 ν•œλ‹€', () => { + const { result, rerender } = renderHook(({ num }) => usePrevious(num), { + initialProps: { num: 100 }, + }); + + rerender({ num: 200 }); + expect(result.current).toBe(100); + + rerender({ num: 300 }); + expect(result.current).toBe(200); + }); + + it('λ¬Έμžμ—΄ 값을 μ •ν™•νžˆ 좔적해야 ν•œλ‹€', () => { + const { result, rerender } = renderHook(({ str }) => usePrevious(str), { + initialProps: { str: 'hello' }, + }); + + rerender({ str: 'world' }); + expect(result.current).toBe('hello'); + + rerender({ str: 'react' }); + expect(result.current).toBe('world'); + }); + + it('boolean 값을 μ •ν™•νžˆ 좔적해야 ν•œλ‹€', () => { + const { result, rerender } = renderHook(({ bool }) => usePrevious(bool), { + initialProps: { bool: true }, + }); + + rerender({ bool: false }); + expect(result.current).toBe(true); + + rerender({ bool: true }); + expect(result.current).toBe(false); + }); + + it('null 값을 좔적할 수 μžˆμ–΄μ•Ό ν•œλ‹€', () => { + const { result, rerender } = renderHook(({ val }) => usePrevious(val), { + initialProps: { val: null }, + }); + + rerender({ val: 'something' }); + expect(result.current).toBeNull(); + + rerender({ val: null }); + expect(result.current).toBe('something'); + }); + }); + + // 객체 ν…ŒμŠ€νŠΈ + describe('객체 κ°’ 좔적', () => { + it('객체λ₯Ό μΆ”μ ν•˜κ³  이전 객체λ₯Ό λ°˜ν™˜ν•΄μ•Ό ν•œλ‹€', () => { + const obj1 = { name: 'John', age: 30 }; + const obj2 = { name: 'Jane', age: 25 }; + + const { result, rerender } = renderHook(({ obj }) => usePrevious(obj), { + initialProps: { obj: obj1 }, + }); + + expect(result.current).toBeUndefined(); + + rerender({ obj: obj2 }); + expect(result.current).toBe(obj1); + expect(result.current.name).toBe('John'); + expect(result.current.age).toBe(30); + }); + + it('μ€‘μ²©λœ 객체λ₯Ό 좔적할 수 μžˆμ–΄μ•Ό ν•œλ‹€', () => { + const obj1 = { user: { name: 'John', profile: { age: 30 } } }; + const obj2 = { user: { name: 'Jane', profile: { age: 25 } } }; + + const { result, rerender } = renderHook(({ obj }) => usePrevious(obj), { + initialProps: { obj: obj1 }, + }); + + rerender({ obj: obj2 }); + expect(result.current.user.name).toBe('John'); + expect(result.current.user.profile.age).toBe(30); + }); + + it('변동 감지에 μ‚¬μš©ν•  수 μžˆμ–΄μ•Ό ν•œλ‹€', () => { + const obj1 = { name: 'John', age: 30 }; + const obj2 = { name: 'Jane', age: 30 }; + + const { result, rerender } = renderHook( + ({ obj }) => { + const prev = usePrevious(obj); + return { + prev, + nameChanged: prev?.name !== obj.name, + ageChanged: prev?.age !== obj.age, + }; + }, + { initialProps: { obj: obj1 } } + ); + + expect(result.current.nameChanged).toBeUndefined(); + + rerender({ obj: obj2 }); + expect(result.current.nameChanged).toBe(true); + expect(result.current.ageChanged).toBe(false); + }); + }); + + // λ°°μ—΄ ν…ŒμŠ€νŠΈ + describe('λ°°μ—΄ κ°’ 좔적', () => { + it('배열을 μΆ”μ ν•˜κ³  이전 배열을 λ°˜ν™˜ν•΄μ•Ό ν•œλ‹€', () => { + const arr1 = [1, 2, 3]; + const arr2 = [4, 5, 6]; + + const { result, rerender } = renderHook(({ arr }) => usePrevious(arr), { + initialProps: { arr: arr1 }, + }); + + expect(result.current).toBeUndefined(); + + rerender({ arr: arr2 }); + expect(result.current).toEqual([1, 2, 3]); + expect(result.current).not.toBe(arr2); + }); + + it('λ°°μ—΄ μš”μ†Œμ˜ 이전 값을 좔적할 수 μžˆμ–΄μ•Ό ν•œλ‹€', () => { + const { result, rerender } = renderHook( + ({ a, b }) => { + const [prevA, prevB] = usePrevious([a, b]) || []; + return { prevA, prevB }; + }, + { initialProps: { a: 1, b: 2 } } + ); + + expect(result.current.prevA).toBeUndefined(); + + rerender({ a: 10, b: 20 }); + expect(result.current.prevA).toBe(1); + expect(result.current.prevB).toBe(2); + + rerender({ a: 100, b: 200 }); + expect(result.current.prevA).toBe(10); + expect(result.current.prevB).toBe(20); + }); + + it('λ³΅μž‘ν•œ λ°°μ—΄ μš”μ†Œλ₯Ό 좔적할 수 μžˆμ–΄μ•Ό ν•œλ‹€', () => { + const arr1 = [{ id: 1 }, { id: 2 }]; + const arr2 = [{ id: 3 }, { id: 4 }]; + + const { result, rerender } = renderHook(({ arr }) => usePrevious(arr), { + initialProps: { arr: arr1 }, + }); + + rerender({ arr: arr2 }); + expect(result.current[0].id).toBe(1); + expect(result.current[1].id).toBe(2); + }); + }); + + // 변동 감지 ν…ŒμŠ€νŠΈ + describe('변동 감지 ν™œμš©', () => { + it('값이 λ³€κ²½λ˜μ—ˆλŠ”μ§€ 감지할 수 μžˆμ–΄μ•Ό ν•œλ‹€', () => { + const { result, rerender } = renderHook( + ({ value }) => { + const prev = usePrevious(value); + return prev !== value; + }, + { initialProps: { value: 'initial' } } + ); + + expect(result.current).toBe(false); + + rerender({ value: 'changed' }); + expect(result.current).toBe(true); + + rerender({ value: 'changed' }); + expect(result.current).toBe(false); + }); + + it('κΉŠμ€ 비ꡐ 없이도 객체 ν•„λ“œ 변경을 감지할 수 μžˆμ–΄μ•Ό ν•œλ‹€', () => { + const { result, rerender } = renderHook( + ({ obj }) => { + const prev = usePrevious(obj); + return { + prevValue: prev?.value, + changed: prev?.value !== obj.value, + }; + }, + { initialProps: { obj: { value: 100 } } } + ); + + rerender({ obj: { value: 200 } }); + expect(result.current.prevValue).toBe(100); + expect(result.current.changed).toBe(true); + }); + + it('μ—¬λŸ¬ ν•„λ“œ 변경을 각각 좔적할 수 μžˆμ–΄μ•Ό ν•œλ‹€', () => { + const { result, rerender } = renderHook( + ({ name, age, email }) => { + const prev = usePrevious({ name, age, email }); + return { + nameChanged: prev?.name !== name, + ageChanged: prev?.age !== age, + emailChanged: prev?.email !== email, + }; + }, + { initialProps: { name: 'John', age: 30, email: 'john@example.com' } } + ); + + rerender({ name: 'Jane', age: 30, email: 'john@example.com' }); + expect(result.current.nameChanged).toBe(true); + expect(result.current.ageChanged).toBe(false); + expect(result.current.emailChanged).toBe(false); + }); + }); + + // μ—£μ§€ μΌ€μ΄μŠ€ + describe('μ—£μ§€ μΌ€μ΄μŠ€', () => { + it('λ™μΌν•œ κ°’μœΌλ‘œ λ¦¬λ Œλ”λ§λ˜λ©΄ 이전 값은 λ³€κ²½λ˜μ§€ μ•Šμ•„μ•Ό ν•œλ‹€', () => { + const { result, rerender } = renderHook(({ value }) => usePrevious(value), { + initialProps: { value: 5 }, + }); + + rerender({ value: 10 }); + expect(result.current).toBe(5); + + rerender({ value: 10 }); + expect(result.current).toBe(5); // μ—¬μ „νžˆ 처음 κ°’ + }); + + it('undefinedλ₯Ό κ°’μœΌλ‘œ 전달할 수 μžˆμ–΄μ•Ό ν•œλ‹€', () => { + const { result, rerender } = renderHook(({ value }) => usePrevious(value), { + initialProps: { value: undefined }, + }); + + expect(result.current).toBeUndefined(); + + rerender({ value: 'something' }); + expect(result.current).toBeUndefined(); + + rerender({ value: undefined }); + expect(result.current).toBe('something'); + }); + + it('0κ³Ό falseλ₯Ό μ •ν™•νžˆ 좔적해야 ν•œλ‹€', () => { + const { result, rerender } = renderHook(({ value }) => usePrevious(value), { + initialProps: { value: 0 }, + }); + + rerender({ value: false }); + expect(result.current).toBe(0); + + rerender({ value: 0 }); + expect(result.current).toBe(false); + }); + + it('빈 λ°°μ—΄κ³Ό 객체λ₯Ό 좔적할 수 μžˆμ–΄μ•Ό ν•œλ‹€', () => { + const emptyArr = []; + const emptyObj = {}; + + const { result: arrResult, rerender: arrRerender } = renderHook( + ({ arr }) => usePrevious(arr), + { initialProps: { arr: emptyArr } } + ); + + arrRerender({ arr: [1, 2, 3] }); + expect(arrResult.current).toEqual([]); + expect(arrResult.current).toBe(emptyArr); + + const { result: objResult, rerender: objRerender } = renderHook( + ({ obj }) => usePrevious(obj), + { initialProps: { obj: emptyObj } } + ); + + objRerender({ obj: { name: 'test' } }); + expect(objResult.current).toEqual({}); + expect(objResult.current).toBe(emptyObj); + }); + }); +}); diff --git a/com.twin.app.shoptime/src/hooks/usePreviousExample.jsx b/com.twin.app.shoptime/src/hooks/usePreviousExample.jsx new file mode 100644 index 00000000..444907d0 --- /dev/null +++ b/com.twin.app.shoptime/src/hooks/usePreviousExample.jsx @@ -0,0 +1,242 @@ +import { useState } from 'react'; +import usePrevious from './usePrevious'; + +/** + * usePrevious ν›…μ˜ λ‹€μ–‘ν•œ μ‚¬μš© μ˜ˆμ‹œλ₯Ό λ³΄μ—¬μ£ΌλŠ” μ»΄ν¬λ„ŒνŠΈ + */ + +// μ˜ˆμ‹œ 1: 단일 κ°’ 좔적 - μΉ΄μš΄ν„° +export function CounterExample() { + const [count, setCount] = useState(0); + const prevCount = usePrevious(count); + + return ( +
+

μ˜ˆμ‹œ 1: 단일 κ°’ 좔적 (μΉ΄μš΄ν„°)

+

ν˜„μž¬κ°’: {count}

+

이전값: {prevCount !== undefined ? prevCount : 'μ΄ˆκΈ°κ°’'}

+ + +
+ ); +} + +// μ˜ˆμ‹œ 2: 닀쀑 κ°’ 좔적 (객체) - μ‚¬μš©μž 정보 +export function UserFormExample() { + const [name, setName] = useState(''); + const [age, setAge] = useState(''); + const [email, setEmail] = useState(''); + + const prev = usePrevious({ name, age, email }); + + const hasNameChanged = prev?.name !== name; + const hasAgeChanged = prev?.age !== age; + const hasEmailChanged = prev?.email !== email; + + return ( +
+

μ˜ˆμ‹œ 2: 닀쀑 κ°’ 좔적 (객체) - μ‚¬μš©μž 정보

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+

이전 κ°’

+

이름: {prev?.name || '(μ—†μŒ)'}

+

λ‚˜μ΄: {prev?.age || '(μ—†μŒ)'}

+

이메일: {prev?.email || '(μ—†μŒ)'}

+
+
+ ); +} + +// μ˜ˆμ‹œ 3: λ°°μ—΄ κ°’ 좔적 - 닀쀑 숫자 +export function ArrayValuesExample() { + const [a, setA] = useState(0); + const [b, setB] = useState(0); + const [c, setC] = useState(0); + + const [prevA, prevB, prevC] = usePrevious([a, b, c]) || []; + + return ( +
+

μ˜ˆμ‹œ 3: λ°°μ—΄ κ°’ 좔적 - 닀쀑 숫자

+ +
+
+ + 이전: {prevA !== undefined ? prevA : 'μ΄ˆκΈ°κ°’'} +
+ +
+ + 이전: {prevB !== undefined ? prevB : 'μ΄ˆκΈ°κ°’'} +
+ +
+ + 이전: {prevC !== undefined ? prevC : 'μ΄ˆκΈ°κ°’'} +
+
+
+ ); +} + +// μ˜ˆμ‹œ 4: 변동 감지 - 데이터 μˆ˜μ • μ—¬λΆ€ 좔적 +export function ChangeDetectionExample() { + const [data, setData] = useState({ + title: 'React Hook Guide', + description: 'usePrevious ν›… μ‚¬μš©λ²•', + views: 1000, + }); + + const prevData = usePrevious(data); + + const changes = { + titleChanged: prevData?.title !== data.title, + descriptionChanged: prevData?.description !== data.description, + viewsChanged: prevData?.views !== data.views, + }; + + const hasAnyChanges = Object.values(changes).some((v) => v); + + const handleUpdate = (field, value) => { + setData((prev) => ({ ...prev, [field]: value })); + }; + + return ( +
+

μ˜ˆμ‹œ 4: 변동 감지 - 데이터 μˆ˜μ • μ—¬λΆ€ 좔적

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+

+ + μƒνƒœ: {hasAnyChanges ? '데이터가 λ³€κ²½λ˜μ—ˆμŠ΅λ‹ˆλ‹€' : 'λͺ¨λ“  데이터가 μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€'} + +

+
+
+ ); +} + +// 전체 μ˜ˆμ‹œ μ‘°ν•© +export function UsePreviousExamples() { + return ( +
+

usePrevious ν›… μ‚¬μš© μ˜ˆμ‹œ

+

이전 값을 μΆ”μ ν•˜λŠ” λ‹€μ–‘ν•œ 방법을 λ³΄μ—¬μ£ΌλŠ” μ˜ˆμ‹œλ“€μž…λ‹ˆλ‹€.

+ + + + + +
+ ); +} + +export default UsePreviousExamples;