원 글 링크
직접 번역 한 글이므로 오역이 있을 수 있으며, 최대한 의역해보았습니다.
리액트의 구조에 관한 여러 논의들중에서, 리액트 컨텍스트는 종종 "상태 관리"를 수행하는 용도로 이야기됩니다.
대표적으로 Kent C의 리액트에서의 상태관리 같은 훌륭한 글들이 실제로 존재합니다.
이러한 이러한 상태 관리 기술은 특정한 경우에서는 유용할수 있지만, 전체 구조적인 면에서는 그렇지 않습니다.
리덕스의 메인테이너인 Mark Erikson이 쓴글 왜 리액트 컨텍스트는 상태관리 용도가 아닌가, 그리고 왜 컨텍스트는 리덕스를 대체하지 않는가라는 글에서, 이 논의에 관한 훌륭한 내용을 다루고 있는 부분과 다른 글들을 참조해서 글을 작성해보았습니다.
이 글에서, 저는 Mark Erikson 의 아이디어와 그리고 그가 이야기한 글들을 확장해서 왜 리액트 컨텍스트는 상태관리 용도가 아닌, 의존성 주입의 도구인지 요약해보려고 합니다.
그가 제공한 몇몇 정의들로 시작해볼까요 ?
여기서 상태관리의 요구사항은 총 4가지로 이루어집니다.
- 초기 상태를 저장한다.
- 현재의 상태값을 읽는다.
- 상태를 업데이트한다.
- 상태 값이 변화할때 그것을 알아차린다.
Redux, MobX, Recoil, Apollo 그리고 React Query 같은 라이브러리들은 위에 기재되어있는 4가지의 "상태 관리 요구 사항"을 수행하며, 상태 관리 라이브러리들로써 분류됩니다.
반면에 리액트 컨텍스트는 이러한 요구사항들을 전부 충족시키지는 못합니다.
기술적으로 컨텍스트는 값을 저장하고, 값을 읽고, 값의 변화를 알아차리지만, 값을 업데이트 하지는 않습니다.
컨텍스트에 저장되어있는 값을 변화시키는 방법은 컨텍스트 Provider에 새로운 prop으로 그 값을 전달하는 것입니다.
하지만 그 prop은 그럼 어디서 오는걸까요 ?
별도의 시스템이나 React State(예를 들어 useState, useReducer, class 컴포넌트의 this.state )에서 발생합니다.
리액트 컨텍스트는 값을 거의 업데이트 할수는 있지만, 완전히는 아닙니다.
Mark는 또한 리액트 컨텍스트 내부에서 값을 직접적으로 저장하는것에 대한 주요한 문제점들 중 하나를 지적했습니다.
컨텍스트가 새로운 상태 값을 받게 된다면, 해당 컨텍스트를 구독하고 있는(해당 컨텍스트의 하위 컴포넌트들을 이야기 하는 것 같습니다.) 컴포넌트들은 강제로 리렌더링이 됩니다. 그들이 오직 데이터에 대한 부분만을 담당하고 있다하더라도요.
지금 이러한 점은 문제를 불러 일으킬수도, 아닐수도 있습니다.
리액트의 코어 팀원 Sebastian Markbage 는 컨텍스트의 사용 경우들을 이렇게 설명합니다.
내 개인적인 의견으로는, 컨텍스트는 빈도가 낮게 업데이트 되는 부분(예를 들어 locale 이나 테마)에서 사용할 준비가 되어있다고 생각합니다.
예전 컨텍스트가 사용되었던 방식처럼 사용하는것도 좋습니다. 예를 들어서 구독을 통해서 정적인 데이터 값들을 업데이트 하는것이요.
모든 Flux 같은 상태 전파를 대체할 준비는 되어있지 않습니다. (상태 관리로는 적합하지 않다는 뜻으로 해석하겠습니다.)
이 글은 어떻게 상태 관리 라이브러리를 선택하고 그것에 접근하는지에 대한 합리적인 권장사항으로 끝납니다.
여기 Mark Erickson이 말했었던 실 예가 있습니다.
거의 모든 상태 관리 라이브러리들은 리액트 컨텍스트를 의존성 주입을 위해 사용하지만, 원시 데이터 전송을 위해선 사용하지 않습니다.
다음은 Redux 나 Recoil, Apollo 그리고 React Query의 소스 코드 일부분 입니다.
이것들은 실질적으로 컨텍스트 안에 저장되는 데이터들을 관리하는 상태스러운 컨테이너임을 보여줍니다.
공식적으로 리액트와 엮여있는 react-redux는 컨텍스트 (ref)를 통해서 구독 객체와 리덕스 스토어를 전달합니다.
import { ReactReduxContext } from './Context'
import Subscription from '../utils/Subscription'
function Provider({ store, context, children }) {
const contextValue = useMemo(() => {
const subscription = new Subscription(store)
subscription.onStateChange = subscription.notifyNestedSubs
return {
store,
subscription,
}
}, [store])
// ... other stuff
const Context = context || ReactReduxContext
return <Context.Provider value={contextValue}>{children}</Context.Provider>
}
React Query 와 Apollo 둘다 "클라이언트" 객체를 컨텍스트를 통해 전달합니다.
이 컨텍스트는 그들의 데이터를 관찰하고 변경하는 옵저버들을 사용합니다.
Recoil은 한가지는 는 순수한 값을, 또 다른 한가지는 변경 가능한 소스를 저장하는 총 2가지의 컨텍스트를 사용합니다.
아시다시피 이 모델들은 관찰/구독 모델을 이행해서 데이터를 관찰하고 데이터를 변경합니다.
Mobx 리액트는 또한 선택적인 "Provider/Inject" 기능을 포함 합니다.
이 기능은 아래에 있는 자식 tree들로 Mobx의 관찰 가능한 항목들을 전달합니다.
이러한 모든 경우들에서, 컨텍스트는 관찰가능한/ 구독 가능한 컨테이너 객체를 다른 라이브러리 코드에 의해 접근 가능한 리액트 컴포넌트 트리로 전달하기 위한 의존성 주입 메카니즘을 위해 사용됩니다.
실제 "상태 관리를 위한 4가지 요구사항"은 리액트 컨텍스트가 아니라 다른 라이브러리들에 의해 이행됩니다.
(즉, 직접 상태 관리에 관여하는 것이 아니라 컨텍스트는 관찰과, 구독을 담당하기 위해 사용되고 직접적인 상태 관리는 다른 라이브러리들에 의해 이루어진다는 뜻 같습니다)
의존성 주입(DI)는 당신의 프로그램에서 의존성들을 관리하기 위한 테크닉입니다.
왜 우리는 이걸 신경 써야할까요 ?
왜냐하면 의존성이라는 것은 "리스크"를 의미하기 때문입니다.
"결합"이라는 것은 많은 프로그래밍 격언과 많은 책들에서 많이 언급되고 우리를 상기 시키는 내용입니다.
"낮은 결합, 높은 응집력"이 그 예중에 하나입니다.
결합은 당신의 코드에서, 코드들간의 종속 정도를 나타냅니다.
한 코드가 다른 코드에 의존하는 정도가 높아지면 질수록, 그 둘의 결합 정도는 강해집니다.
몇몇 결합과 의존은 필수적입니다. 왜냐하면 그것들 없이는 당신의 코드를 추상화 할수 없기 때문입니다.
또 추상화없이는 우리는 기껏해야 CPU 명령으로 우리의 모든 소프트웨어를 작성하거나, 최악의 경우에는 회로 기판을 배선할 것입니다.
의존성 주입은 그들을 하드코딩하여서 직접 주입하는것보다, 모듈이나 컴포넌트들 간에 의존성을 넘겨줌으로써 간단해 질수 있습니다.
이 Fetch API를 사용하는 HttpClient 라는 자바스크립트 클래스를 한번 살펴보겠습니다.
이 클래스는 백엔드로부터 데이터를 요청합니다.
// services/HttpClient.js
export class HttpClient {
async fetchProducts() {
const resp = await fetch('/api/products');
return resp.json();
}
async fetchOrders() {
const resp = await fetch('/api/orders');
return resp.json();
}
// ... more API endpoint stuff ...
}
그리고, 이 HttpClient 는 더 고차원 수준에서의 ProductService에서 사용 됩니다.
// services/ProductsService.js
import {HttpClient} from './HttpClient.js'
export class ProductsService {
constructor() {
this.client = new HttpClient();
}
async lookupNewProducts() {
const products = await this.client.fetchProducts();
return products.filter(product => product.isNew);
}
async lookupProductsWithPromo() {
const products = await this.client.fetchProducts();
return products.filter(product => product.promos.length > 0);
}
// ... more product specific stuff ...
}
위의 코드는 "생성자 주입"이라는 잘 알려진 의존성 주입 테크닉을 수행하고 있는것처럼 보입니다.
import {HttpClient} from './HttpClient.js'
export class ProductsService {
constructor(client) {
this.client = client || new HttpClient();
}
// ... everything else the same ...
}
위의 코드에서는, "client"라고 하는 생성자 인자를 받거나, 그게 아니라면 HttpClient 객체를 사용합니다.
이 코드의 변화가 작아보일지라도, 이는 우리가 더 쉽게 테스트 할수 있고 더 안전하게 우리의 코드를 재사용할수 있는 이점을 줍니다.
한번 jest에서 우리가 client를 직접 주입하기 전에는 어떤식으로 코드가 쓰여졌는지 봅시다.
// services/__tests__/ProductsService.test.js
import {ProductsService} from '../ProductsService.js';
import {HttpClient} from './HttpClient.js';
// Setup our method mock and then the import mock. I need to
// look this particular recipe up everytime I use it.
const mockFetchProducts = jest.fn();
jest.mock('./HttpClient.js', () => {
return jest.fn().mockImplementation(() => {
return {fetchProducts: mockFetchProducts}
})
});
// Don't forget these lines or else our tests will bleed
// into eachother.
beforeEach(() => {
HttpClient.mockClear();
mockFetchProducts.mockClear();
})
describe('ProductsService', () => {
describe('lookupNewProducts()', () => {
it('filters for only new products', async () => {
// Set the inner mock of the inner dependency to
// return our test data
mockFetchProducts.mockReturnValueOnce([
{ id: 1, isNew: true },
{ id: 2, isNew: false }
]);
const service = new ProductsService();
const result = await service.lookupNewProducts();
expect(result).toHaveLength(1)
expect(result[0]).toEqual({id: 1, isNew: true})
})
})
// ... etc.
})
이제 ProductService에 의존성 주입을 했을때의 코드입니다.
// services/__tests__/ProductsService.test.js
import {ProductsService} from '../ProductsService.js';
describe('ProductsService', () => {
describe('lookupNewProducts()', () => {
it('filters for only new products', async () => {
// Simply pass a fake object that implements the same
// client interface needed by the system under test
const service = new ProductsService({
async fetchProducts() {
return [{ id: 1, isNew: true }, { id: 2, isNew: false }]
}
});
const result = await service.lookupNewProducts();
expect(result).toHaveLength(1)
expect(result[0]).toEqual({id: 1, isNew: true})
})
})
// ... etc.
})
훨씬 깔끔해지고, 정확해졌으며 더 많은 장점들을 가집니다.
1. mocking이 필요하지 않습니다.
2. 서로의 테스트의 충돌을 피하기 위해 mock을 재설정하는것이 요구되지않습니다.
3. 테스트 러너(jest)의 특정 기능에 의존하지 않습니다.
테스팅을 넘어서, 만약 특정 유저가 HTTP 헤더에 어떠한 값들을 설정해야하거나 할때 우리가 해야할 특별한 셋팅은 무엇일까요 ?
우리는 직접 클라이언트를 인스턴스화 하고 우리의 서비스에 값을 전달하기 전에 우리가 원하는 구성을 제공할 수 있습니다.
const client = new HttpClient()
client.setDefaultHeaders({'X-APP-ROLE': 'cool user'});
const service = new ProductsService(client);
서비스는 이러한 변화들에도 명확성을 유지합니다.
우리가 만약에 우리의 백엔드 API를 GraphQL로 마이그레이션 해야한다고 가정해봅시다.
우리는 우리의 똑같은 ProductsService를 똑같은 기능을 하고 똑같은 값을 전달하는 GraphQLClient로 대체해서, 그걸 생성자에 주입하기만 하면 됩니다.
해당 인스턴스에선 GraphQL을 수행하고 다른 인스턴스들에선 REST를 수행할 것입니다.
const client = checkIfBetaAccount()
? new GraphQLClient()
: new HttpClient();
const service = new ProductsService(client);
의존성 주입이 클래스나 다른 객체 지향적인 코드에서만 사용된다고 생각하기전에, 여기 closure를 사용한 동일한 개념이 존재합니다.
// services/productServicesFactory.js
export const productServicesFactory = initialClient => {
const client = initialClient || new HttpClient();
return {
async lookupAllProducts() {
return client.fetchProducts();
}
async lookupNewProducts() {
const products = await client.fetchProducts();
return products.filter(product => product.isNew);
}
async lookupProductsWithPromo() {
const products = await client.fetchProducts();
return products.filter(product => product.promos.length > 0);
}
}
};
// Usage:
// const {lookupNewProducts} = productServicesFactory();
// lookupNewProducts.then(newProducts => { /* do stuff */ });
그래서 우리는 어떻게 간단한 의존성 주입을 리액트에서 사용할수 있을까요 ?
그리고 이러한 부분이 우리에게 주는 장점은 무엇일까요 ?
한번 작은 예를 들어보겠습니다.
// components/Products.jsx
import React from 'react';
const INITIAL_STATE = {
loading: true,
error: null,
products: []
};
function Products() {
const [response, setResponse] = useState(INITIAL_STATE);
useEffect(() => {
fetch('/api/products')
.then(resp => resp.json())
.then(data => setResponse({loading: false, products: data}))
.catch(error => setResponse({loading: false, error}))
}, [])
if (response.loading) {
return <LoadingSpinner />
}
if (response.error) {
return <ErrorPage error={response.error} />
}
return (
<div>
{response.products.map(product => (
<Product {...product} />
))}
</div>
)
}
지금 이 컴포넌트를 어떻게 React Testing Library에서 자동화되도록 코드를 작성해볼수 있을까요 ?
당신은 일단 네트워크 계층을 모형화 해야합니다.
다음은 RTL 문서에서 가져온 예시입니다.
// components/__tests__/Products.test.jsx
import React from 'react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { render, waitFor, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import {Products} from '../Products.jsx'
// Mock out the entire network layer!
const server = setupServer(
rest.get('/api/products', (req, res, ctx) => {
return res(ctx.json([
{id: 1, title: 'First Product', /* more data */},
{id: 2, title: 'Second Product', /* more data */}
]))
})
)
// Don't forget these or else your test cases and test suites
// will bleed together :/
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('loads and displays products', async () => {
render(<Products />)
// Wait for some UI element that appears when loading finishes
await waitFor(() => screen.getByText('Products List'))
// Make assertions on what should be on the screen
expect(screen.queryByTitle('First Product')).toBeInTheDocument()
// ... moar assertions ...
})
test('loads a different list of products', async () => {
// shadow the URL handler with a NEW handler
server.use(
rest.get('/api/products', (req, res, ctx) => {
return res(ctx.json([
{id: 3, title: 'Third PROMO Product', promotion: {}},
{id: 4, title: 'Fourth Product'}
]))
})
);
render(<Products />)
// moar awaits and assertions
})
우리의 리액트 컴포넌트에서 global Fetch API를 사용하기 때문에, 우리는 전체의 네트워크 서비스를 모킹 할수 있도록 준비해야합니다.
그리고 우리는 우리가 쓴 코드에 대한 모든 컴포넌트들을 테스트 해줄 필요가 있습니다.
좋습니다. 확실한 접근법이 있습니다.
하지만 모킹 자체가 아니라, 우리의 컴포넌트 디자인에 관해서 고려해보아야할 점들이 있습니다.
만약 우리의 API 엔드포인트가 바뀌고 데이터 포맷이 변화한다면, 우리는 모든 리액트 컴포넌트들을 수정해서 테스트와 일치하도록 업데이트 해주어야합니다.
수많은 에러 케이스들 (400, 401, 404, 500 등)을 핸들링해주려면 우리의 뷰 컴포넌트에서 그것들을 제대로 다뤄줄 필요가 있습니다.
왜 저의 뷰 컴포넌트들은 네트워크 재요청에 대해서 아는걸까요 ?
이제 우리는 분리된 함수나 모듈을 추출하고 몇몇 간접적인 방법을 통해서 이들을 더 잘 전달할수 있습니다.
하지만 우리가 만약 더 나아가서 책의 한페이지를 떼오는것 마냥 Redux나 Recoil, Apollo 같은 라이브러리들을 컨텍스트를 통해 이 컴포넌트에 주입해주면 어떨까요 ?
먼저 커스텀 컨텍스트를 한번 만들어봅시다.
// DepsContext.js
import {createContext, useContext} from 'react';
const DepsContext = createContext({});
export function useDeps() {
return useContext(DepsContext);
}
export function DepsProvider({children, ...services}) {
return (
<DepsContext.Provider value={services}>
{children}
</DepsContext.Provider>
)
}
그리고 우리의 리액트 트리에 우리의 컨텍스트 Provider를 알려줍시다.
우리는 이 컨텍스트를 App 최상단에 둘거고, 이건 절대 바뀌지 않을거에요.
// App.jsx
import React from 'react';
import {DepsProvider} from './DepsContext.js';
import {Products} from './components/Products.jsx';
import {
productServicesFactory
} from './services/productServicesFactory.js';
export function App() {
return (
<DepsProvider productServicesFactory={productServicesFactory}>
<Products />
</DepsProvider>
)
}
그리고 컨텍스트 값들을 우리의 서비스 팩토리 함수를 가져오기 위해 사용하고, fetch함수들을 다 useEffect 안에서 호출해봅시다.
// components/Products.jsx
import {useDeps} from '../DepsContext.js';
// ... same ...
function Products() {
const {productServicesFactory} = useDeps();
const [response, setResponse] = useState(INITIAL_STATE);
useEffect(() => {
const {lookupAllProducts} = productServicesFactory();
lookupAllProducts()
.then(data => setResponse({loading: false, products: data}))
.catch(error => setResponse({loading: false, error}));
// Add to deps array cuz we're good citizens, but it won't
// break if we didn't
}, [productServicesFactory]);
// ... same ...
}
컴포넌트의 변화가 매우 작고 구성요소들이 과소평가 되기가 쉬우니까 잘 보세요. (별 차이점이 없다고 느낄수도 있으니 잘 살펴보라는 뜻으로 해석하겠습니다.)
겉보기에는, 우리가 fetch를 그냥 lookupAllProducts함수로 바꾼거 같은거 뿐입니다.
그래서 별 흥미가 안느껴지실수도 있어요.
하지만 컴포넌트의 첫번째 줄을 살펴보세요.
const {productServicesFactory} = useDeps();
우리는 전역 fetch를 우리의 커스텀 함수로 지정하지 않았습니다.
우리는 컨텍스트를 통해 컴포넌트로 우리의 커스텀 함수를 주입했습니다.
이러한 변화의 의미들은 우리가 테스트나 다른 방식으로 우리의 컴포넌트들을 사용하고 싶을때 더욱 명확해집니다.
// components/__tests__/Products.test.jsx
import React from 'react'
import { render, waitFor, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import {Products} from '../Products.jsx'
import {DepsProvider} from '../../DepsContext.jsx'
const getFactory = data => () => {
return {
async lookupAllProducts() {
return data
}
}
}
test('loads and displays products', async () => {
render(
<DepsProvider
productServicesFactory={getFactory([
{id: 1, title: 'First Product', /* more data */},
{id: 2, title: 'Second Product', /* more data */},
])}
>
<Products />
</DepsProvider>
)
// Wait for some UI element that appears when loading finishes
await waitFor(() => screen.getByText('Products List'))
// Make assertions on what should be on the screen
expect(screen.queryByTitle('First Product')).toBeInTheDocument()
// ... moar assertions ...
})
test('loads a different list of products', async () => {
render(
<DepsProvider
productServicesFactory={getFactory([
{id: 3, title: 'Third PROMO Product', promotion: {}},
{id: 4, title: 'Fourth Product'}
])}
>
<Products />
</DepsProvider>
)
// moar awaits and assertions
})
써드파티 패키지와 개념들의 표면적인 부분들이 훨씬 간단해졌습니다. (코드 자체가 직관적이게 됐다고 해석하겠습니다.)
다른 네트워크나 서비스 워커를 모킹할 필요가 없습니다.
모킹 자체의 import가 없습니다.
다른 테스트 환경에서의 어떤 의존성을 가지고 있지 않습니다. (테스트 환경이 변해도 테스트 코드는 독립적으로 작동합니다 라고 해석하겠습니다.)
스토리북의 미리보기 파일은 어떨까요 ?
// components/__stories__/Products.stories.js
import React from 'react';
import { DepsProvider } from '../../DepsContext.jsx'
import { Products } from '../Products';
const productServicesFactory = () => {
return {
async lookupAllProducts() {
return [
{id: 1, title: 'First Product', /* more data */},
{id: 2, title: 'Second Product', /* more data */},
]
}
}
}
export default {
title: 'Products',
component: Products,
decorators: [
(Story) => (
<DepsProvider productServicesFactory={productServicesFactory}>
<Story />
</DepsProvider>
),
],
};
이건 꽤나 우리의 테스트 파일과 비슷해보이네요.
왜냐하면 우리가 바꾼 이 코드, 이 해결책은 스토리북이나 제스트 라이브러리들에 한정되어있지 않습니다.
우리는 이 해결책들을 표준적인 엔지니어링 환경들에 적용시킬수 있고, 공용으로 사용되는 모듈들에 똑같이 적용할 수 있습니다.
(공통적으로 현재 사용되는 환경들마다 큰 변화 없이 그대로 모듈을 가져다 적용시킬수 있다는 뜻으로 해석하겠습니다.)
만약 우리가 우리의 테스트들에서 목을 사용했다면 어떨까요 ??
우리는 목 서비스 워커(스토리북에선 addon같은)를 설치했어야 했을겁니다.
이러면 테스트 환경과 스토리북 환경에서 우리의 네트워크 문제를 해결해줄수야 있겠죠.
하지만 제일 중요한 근본적인 문제는 해결되지 않습니다.
또한 네트워크 환경만 모킹하면 된다고 얼마나 확신하시나요 ? 전 그러진 못합니다.
그리고 기억하세요. 우리의 테스트 환경이 변화한다면, 목킹이 어려워집니다.
그래서 의존성 주입은 우리의 어플리케이션 내에서 코드들을 결합해주는데에 도움을 줄 뿐만 아니라, 테스트 환경이나 프리뷰 환경에서도 도움을 줍니다.
왜일까요? 왜냐하면 우리의 코드는 의존성에 관해서 더 유연해졌고, 이는 더 많은 곳들에서 자연스럽게 사용될수가 있기 때문이죠.
우리는 작은 투자를 함으로써 엄청 큰 재사용성을 얻게 된 셈입니다.
이건 다른 큰 변화들이 있습니다.
이전에 제가 말했던 예시처럼, 의존성 주입은 우리가 구현을 쉽게 변화시킬수 있도록 합니다.
// App.jsx
// ... same imports ...
import {useIsBetaOptInUser} from './hooks'
import {
productServicesFactory,
NEW_productServicesFactory
} from './services/productServicesFactory.js';
export function App() {
const isBeta = useIsBetaOptInUser();
const factoryDep = isBeta
? NEW_productServicesFactory
: productServicesFactory;
return (
<DepsProvider productServicesFactory={factoryDep}>
<Products />
</DepsProvider>
)
}
이게 아니면 엄청 러프해질수도 있죠.
// App.jsx
// ... same imports ...
export function App() {
const isBeta = useIsBetaOptInUser();
return (
isBeta ? (
<DepsProvider productServicesFactory={NEW_factory}>
<BetaApp>
<Products />
<NewComponent />
<AnotherNewComponent />
</BetaApp>
</DepsProvider>
) : (
<DepsProvider productServicesFactory={OLD_factory}>
<LegacyApp>
<Products />
</LegacyApp>
</DepsProvider>
)
);
}
2가지의 시나리오들에서 어떠한 것들도 Products 컴포넌트는 변화할 필요가 없다는걸 알아야합니다.
이게 바로 리액트 컨텍스트를 사용한 장점입니다.
그리고 몇줄 안되는 코드로 작업을 온전히 수행했고, 변경 가능성이 거의 없는 API를 사용했으며,
또 우리가 온전히 컨트롤 가능합니다.
이건 유지보수성에 엄청난 장점입니다!
리액트 컨텍스트를 "상태 관리용 도구"로 생각하지 마세요.
"의존성을 관리하기 위한 도구"로 생각하세요.
당신이 만약에 리액트에서 불필요한 의존성을 발견하게 됐다면, 그것을 따로 리액트 컨텍스트에서 주입해주는 것으로 고려해보세요.
현재 인기 많은 라이브러리들은 모두 그렇게 해주고 있습니다. (리덕스나 아폴로, 리액트 쿼리, 리코일 등.. 당신도 마찬가지여야만 하고요!)
당신의 컴포넌트들은 더 테스트 하기 쉬워지고, 더 재사용성이 높아지고, 더 높은 유지보수성을 갖게 될거에요.