diff --git a/.changeset/curly-seahorses-sit.md b/.changeset/curly-seahorses-sit.md
new file mode 100644
index 000000000..f88d2a0a9
--- /dev/null
+++ b/.changeset/curly-seahorses-sit.md
@@ -0,0 +1,5 @@
+---
+'@modern-kit/react': minor
+---
+
+feat(react): useFileReader 훅 추가
diff --git a/docs/docs/react/hooks/useFileReader.mdx b/docs/docs/react/hooks/useFileReader.mdx
new file mode 100644
index 000000000..1f026c6b4
--- /dev/null
+++ b/docs/docs/react/hooks/useFileReader.mdx
@@ -0,0 +1,63 @@
+# useFileReader
+
+`File` 객체를 원하는 읽기 메서드(`readAsText`,`readAsDataURL`,`readAsArrayBuffer`)로 읽고, 읽은 파일 컨텐츠를 반환하는 커스텀 훅입니다.
+
+
+
+## Interface
+```tsx
+type ReadType = 'readAsText' | 'readAsDataURL' | 'readAsArrayBuffer';
+
+interface FileContent {
+ status: 'fulfilled' | 'rejected';
+ readValue: string | ArrayBuffer;
+ originFile: Nullable;
+}
+
+interface ReadFileOptions {
+ file: FileList | File;
+ readType: ReadType;
+ accepts?: string[];
+}
+
+const useFileReader: () => {
+ readFile: ({
+ file,
+ readType,
+ accepts,
+ }: ReadFileOptions) => Promise;
+ fileContents: FileContent[];
+ isLoading: boolean;
+};
+```
+
+## Usage
+
+```tsx
+import React, { useState } from 'react';
+import { useFileReader } from '@modern-kit/react';
+
+const Example = () => {
+ const { readFile, fileContents, loading } = useFileReader()
+
+ const handleChange = (e: React.ChangeEvent) => {
+ if(!e.target.files) return;
+
+ readFile({ file: e.target.files, readType: 'readAsText' });
+ /*
+ * 1. readFile은 Promise 반환합니다. 해당 값은 fileContents와 동일합니다.
+ * ex) const data = await readFile(e.target.files, 'readAsDataURL');
+ *
+ * 2. accepts로 원하는 파일 타입만 읽어올 수 있습니다.
+ * accepts옵션을 넘겨주지 않으면 모든 파일 타입을 허용합니다.
+ * ex) readFile({ file: e.target.files, readType: 'readAsText', accepts: ['text/plain'] });
+ */
+ }
+
+ return (
+
+
+
+ );
+};
+```
\ No newline at end of file
diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts
index ae78ba8c4..d88a78aed 100644
--- a/packages/react/src/hooks/index.ts
+++ b/packages/react/src/hooks/index.ts
@@ -2,6 +2,7 @@ export * from './useAsyncPreservedCallback';
export * from './useAsyncProcessQueue';
export * from './useBlockPromiseMultipleClick';
export * from './useDebounce';
+export * from './useFileReader';
export * from './useForceUpdate';
export * from './useImageStatus';
export * from './useIntersectionObserver';
diff --git a/packages/react/src/hooks/useFileReader/index.ts b/packages/react/src/hooks/useFileReader/index.ts
new file mode 100644
index 000000000..8e30b580f
--- /dev/null
+++ b/packages/react/src/hooks/useFileReader/index.ts
@@ -0,0 +1,97 @@
+import { Nullable } from '@modern-kit/types';
+import { useState } from 'react';
+
+type ReadType = 'readAsText' | 'readAsDataURL' | 'readAsArrayBuffer';
+
+interface FileContent {
+ status: 'fulfilled' | 'rejected';
+ readValue: string | ArrayBuffer;
+ originFile: Nullable;
+}
+
+interface ReadFileOptions {
+ file: FileList | File;
+ readType: ReadType;
+ accepts?: string[];
+}
+
+const isFile = (file: FileList | File): file is File => {
+ return file instanceof File;
+};
+
+const isFileList = (file: FileList | File): file is FileList => {
+ return file instanceof FileList;
+};
+
+const inValidFileType = (file: FileList | File) => {
+ return !isFile(file) && !isFileList(file);
+};
+
+const getFiles = (file: File | FileList, accepts: string[]) => {
+ const files = isFile(file) ? [file] : Array.from(file);
+
+ return accepts.length > 0
+ ? files.filter((file) => accepts.includes(file.type))
+ : files;
+};
+
+const getReaderPromise = (reader: FileReader, file: File) => {
+ return new Promise>((resolve, reject) => {
+ reader.onload = () => {
+ resolve(reader.result);
+ };
+ reader.onerror = () => {
+ reject(`Failed to read file ${file.name}`);
+ };
+ });
+};
+
+export const useFileReader = () => {
+ const [fileContents, setFileContents] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const readFile = async ({
+ file,
+ readType,
+ accepts = [],
+ }: ReadFileOptions) => {
+ if (inValidFileType(file)) {
+ return [];
+ }
+
+ const files = getFiles(file, accepts);
+
+ setIsLoading(true);
+ setFileContents([]);
+
+ const readerPromises = files.map((file) => {
+ const reader = new FileReader();
+
+ try {
+ reader[readType](file);
+ } catch {
+ return Promise.reject(`Failed to read file ${file.name}`);
+ }
+
+ return getReaderPromise(reader, file);
+ });
+
+ const settledPromises = await Promise.allSettled(readerPromises);
+ const contents: FileContent[] = settledPromises.map((el, idx) => {
+ const isFulfilled = el.status === 'fulfilled';
+
+ return {
+ status: el.status,
+ readValue: isFulfilled ? el.value : el.reason,
+ originFile: isFulfilled ? files[idx] : null,
+ };
+ });
+
+ setFileContents(contents);
+ setIsLoading(false);
+
+ return contents;
+ };
+
+ return { readFile, fileContents, isLoading };
+};
diff --git a/packages/react/src/hooks/useFileReader/useFileReader.spec.ts b/packages/react/src/hooks/useFileReader/useFileReader.spec.ts
new file mode 100644
index 000000000..81bd8d85d
--- /dev/null
+++ b/packages/react/src/hooks/useFileReader/useFileReader.spec.ts
@@ -0,0 +1,168 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { useFileReader } from '.';
+import {
+ MockFileReaderForcedCallOnError,
+ MockFileReaderThrowError,
+ mockFileList,
+} from '../../utils/test/mockFile';
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+const getSuccessFileContent = (file: File) => {
+ return {
+ status: 'fulfilled',
+ readValue: 'file',
+ originFile: file,
+ };
+};
+
+const testFile1 = new File(['file'], 'test1.txt', {
+ type: 'text/plain',
+});
+const testFile2 = new File(['file'], 'test2.csv', {
+ type: 'text/csv',
+});
+
+const testFileList = mockFileList([testFile1, testFile2]);
+const errorTestFile = '' as any;
+
+const errorFileContent = {
+ status: 'rejected',
+ readValue: 'Failed to read file test1.txt',
+ originFile: null,
+};
+
+describe('useFileReader', () => {
+ describe('Success Case', () => {
+ it('should return the normal file contents in "fileContents" when a value of type "File" is passed as an argument to "readFile"', async () => {
+ const { result } = renderHook(() => useFileReader());
+ const expectedSuccessFileContents = [getSuccessFileContent(testFile1)];
+
+ await waitFor(async () => {
+ const fileContents = await result.current.readFile({
+ file: testFile1,
+ readType: 'readAsText',
+ });
+
+ expect(result.current.isLoading).toBeTruthy();
+ expect(fileContents).toEqual(expectedSuccessFileContents);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBeFalsy();
+ expect(result.current.fileContents).toEqual(
+ expectedSuccessFileContents
+ );
+ });
+ });
+
+ it('should return the normal file contents in "fileContents" when a value of type "FileList" is passed as an argument to "readFile"', async () => {
+ const { result } = renderHook(() => useFileReader());
+ const expectedSuccessFileContents = [
+ getSuccessFileContent(testFile1),
+ getSuccessFileContent(testFile2),
+ ];
+
+ await waitFor(async () => {
+ const fileContents = await result.current.readFile({
+ file: testFileList,
+ readType: 'readAsText',
+ });
+
+ expect(result.current.isLoading).toBeTruthy();
+ expect(fileContents).toEqual(expectedSuccessFileContents);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBeFalsy();
+ expect(result.current.fileContents).toEqual(
+ expectedSuccessFileContents
+ );
+ });
+ });
+
+ it('should only read files of types specified in the "accepts" attribute', async () => {
+ const { result } = renderHook(() => useFileReader());
+ const expectedSuccessFileContents = [getSuccessFileContent(testFile2)];
+
+ await waitFor(async () => {
+ const fileContents = await result.current.readFile({
+ file: testFileList,
+ readType: 'readAsText',
+ accepts: ['text/csv'],
+ });
+ expect(result.current.isLoading).toBeTruthy();
+ expect(fileContents).toEqual(expectedSuccessFileContents);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBeFalsy();
+ expect(result.current.fileContents).toEqual(
+ expectedSuccessFileContents
+ );
+ });
+ });
+ });
+
+ describe('Error Case', () => {
+ // Line: getReaderPromise - reader.onerror
+ it('should return the error contents in "fileContents" when "reader.onerror" is called', async () => {
+ const { result } = renderHook(() => useFileReader());
+ const failedExpectedFileContents = [errorFileContent];
+
+ vi.stubGlobal('FileReader', MockFileReaderForcedCallOnError);
+
+ await waitFor(async () => {
+ const fileContents = await result.current.readFile({
+ file: testFile1,
+ readType: 'readAsText',
+ });
+ expect(fileContents).toEqual(failedExpectedFileContents);
+ });
+
+ await waitFor(() => {
+ expect(result.current.fileContents).toEqual(failedExpectedFileContents);
+ });
+ });
+
+ // Line: readerPromises - catch
+ it('should return the error contents in "fileContents" if an error occurs during the call to "reader[readType]"', async () => {
+ const { result } = renderHook(() => useFileReader());
+ const failedExpectedFileContents = [errorFileContent];
+
+ vi.stubGlobal('FileReader', MockFileReaderThrowError);
+
+ await waitFor(async () => {
+ const fileContents = await result.current.readFile({
+ file: testFile1,
+ readType: 'readAsText',
+ });
+
+ expect(fileContents).toEqual(failedExpectedFileContents);
+ });
+
+ await waitFor(() => {
+ expect(result.current.fileContents).toEqual(failedExpectedFileContents);
+ });
+ });
+
+ // Line: inValidFileType
+ it('should return an empty array for "fileContents" if the argument to "readFile" is neither of type "File" nor "FileList"', async () => {
+ const { result } = renderHook(() => useFileReader());
+
+ await waitFor(async () => {
+ const fileContents = await result.current.readFile({
+ file: errorTestFile,
+ readType: 'readAsText',
+ });
+ expect(fileContents).toEqual([]);
+ });
+
+ await waitFor(() => {
+ expect(result.current.fileContents).toEqual([]);
+ });
+ });
+ });
+});
diff --git a/packages/react/src/utils/test/mockFile.ts b/packages/react/src/utils/test/mockFile.ts
new file mode 100644
index 000000000..b3688cfab
--- /dev/null
+++ b/packages/react/src/utils/test/mockFile.ts
@@ -0,0 +1,42 @@
+export const mockFileList = (files: File[]): FileList => {
+ const input = document.createElement('input');
+ input.setAttribute('type', 'file');
+ input.multiple = true;
+
+ const fileList: FileList = Object.create(input.files);
+
+ files.forEach((file, idx) => {
+ fileList[idx] = file;
+ });
+
+ Object.defineProperty(fileList, 'length', { value: files.length });
+ return fileList;
+};
+
+export class MockFileReaderForcedCallOnError {
+ onerror: (...args: any[]) => any;
+
+ constructor() {
+ this.onerror = () => {};
+ }
+
+ readAsText(file: File) {
+ if (this.onerror) {
+ setTimeout(() => {
+ this.onerror(new Error(`Error, ${file.name}`));
+ }, 0);
+ }
+ }
+}
+
+export class MockFileReaderThrowError {
+ onerror: (...args: any[]) => any;
+
+ constructor() {
+ this.onerror = () => {};
+ }
+
+ readAsText() {
+ throw new Error('Failed to read file');
+ }
+}