مدیریت State در React

ترجمه و تالیف : مهدی عقیقی
تاریخ انتشار : 23 فروردین 99
خواندن در 4 دقیقه
دسته بندی ها : react

می‌توان گفت، مدیریت State تقریبا در هر برنامه‌ای، سخت‌ترین کار است. به همین دلیل است که تعداد بسیار زیادی کتاب‌خانه‌های مدیریت State وجود دارند و هر روز به آن‌ها اضافه می‌شود. با این که قبول دارم مدیریت State کار بسیار سختی است، معتقدم یکی از دلایل این قضیه، این است که ما در بیشتر مواقع حل مشکلات‌مان را بیش از حد مهندسی می‌کنیم و زیاده‌روی می‌کنیم.

یک روش برای مدیریت State وجود دارد که من از زمانی که استفاده از ریکت را شروع کردم، سعی کردم که از آن استفاده کنم. و با انتشار Hookها و تغییرات بسیار زیاد در Context این روش مدیریت State بسیار ساده شد.

ما بیشتر مواقع در مورد این که ریکت مانند بلاک‌های لگو می‌باشد، صحبت می‌کنیم. و فکر می‌کنم وقتی مردم همچین چیزی را می‌شنوند، فکر می‌کنند که باید بحث State را از این قضیه جدا بدانند. راز من برای این روش مدیریت State، فکر کردن به این است که Stateهای ما چگونه به ساختار درختی برنامه‌ی ما مربوط می‌شوند.

یکی از دلایلی که react-redux بسیار موفق بود این موضوع بود که ریداکس مشکل prop-drilling را حل می‌کرد. این موضوع که شما می‌توانستید به راحتی بین بخش‌های مختلف ساختار درختی‌تان، به راحتی با پاس دادن کامپوننت مورد نظرتان به متد جادویی connect، اطلاعات جا به جا کنید، شگفت‌انگیز بود. استفاده‌اش از reducerها و actionها و ... نیز به آن قوت می‌بخشید. اما معتقدم که حضور آن در همه جا به این دلیل است که مشکل prop drilling را حل می‌کرد و نه چیز دیگر.

دلیلی که من تنها یک‌بار در یک پروژه از ریکت استفاده کردم این است: من همواره می‌دیدم که برنامه‌نویس‌ها تمام stateهای برنامه‌شان را در ریداکس می‌گذارند. نه فقط stateهای global بلکه حتی stateهای local را نیز با استفاده از ریداکس مدیریت می‌کردند. و این موضوع باعث مشکلات بسیار زیادی می‌شود. برای مثال وقتی شما می‌خواهید تعاملات state در برنامه‌تان بگذارید، شما را مجبور به درگیر شدن با reducerها، actionها، creator/typeها و dispatchها می‌کند که در نهایت باعث: باز بودن فایل‌های زیاد در ادیتور، و ... می‌شود.

در نهایت، استفاده از ریداکس برای stateهایی که واقعا global هستند موردی ندارد. اما برای stateهای ساده (مثل باز بودن یک مودال یا مقدار‌های داخل Input های صفحه) یک مشکل بزرگ است. هر چه برنامه‌ی شما بزرگ‌تر شود، این مشکل بزرگ‌تر می‌شود. درست است که شما می‌توانید از reducerهای مختلف برای بخش‌های مختلف برنامه‌تان استفاده کنید، اما تمایل به گذر کردن از این همه مرحله‌ی actionها و reducer‌ها و ... بهینه نیست.

نگه‌ داشتن همه‌ی state برنامه در یک آبجکت نیز، باعث مشکلات زیادی می‌شود. حتی اگر از ریداکس استفاده نمی‌کنید. زمانی‌که یک <Context.Provider> در ریکت، یک مقدار جدید دریافت کند، تمام کامپوننت‌هایی که از آن استفاده می‌کنند، ری‌رندر می‌شوند. حتی اگر از بخشی از آن اطلاعات استفاده کنند. این قضیه می‌تواند باعث مشکلات پرفورمنسی بسیاری بشود. (react-redux ورژن ۶ هم سعی به استفاده از این روش کرد تا زمانی‌که فهمیدند که با استفاده از hookها درست کار نمی‌کند. که مجبورشان کرد در ورژن ۷ از روش دیگری استفاده کنند.) اما حرف من این است که اگر شما stateهای خود را در جای درست و منطقی‌تر و نزدیک به همان‌جاهایی که نیازشان دارید نگه دارید، دیگر این مشکل را نخواهید داشت.

حرف اصلی این است. اگر شما دارید با استفاده از ریکت یک برنامه‌ درست می‌کنید، شما در حال حاضر یک کتاب‌خانه‌ی مدیریت State دارید که در برنامه‌تان نصب است. حتی نیاز به npm install کردن نیز ندارید. هیچ حجم اضافه‌ای در برنامه شما ندارد و تمام کتاب‌خانه‌های ریکت در Npm سازگار است و حتی مستندات عالی که خود تیم ریکت طراحی کرده نیز دارد. راجع به خود ریکت صحبت می‌کنم.

ریکت یک کتاب‌خانه‌ی مدیریت State است

وقتی یک برنامه‌ی ریکت درست می‌کنید، شما در واقع دارید یک‌سری کامپوننت را بغل هم می‌گذارید تا ساختار درختی که از <App /> شروع می‌شود و در <Input /> یا <Button /> یا ... پایان می‌باید، بسازید. شما تمامی کامپوننت‌های لول پایین که برنامه‌تان رندر می‌کند را در یک مکان مرکزی مدیریت نمی‌کنید. شما به هر کدام از کامپوننت‌ها به طور جداگانه اجازه می‌دهید که آن را مدیریت کنند که این یک روش عالی برای ساختن UI‌های فوق‌العاده ا‌ست.

شما با State هم می‌توانید این کار را بکنید. و دقیقا همین کاری است که هم‌اکنون انجام می‌دهید.

function Counter() {
  const [count, setCount] = React.useState(0)
  const increment = () => setCount(c => c + 1)
  return <button onClick={increment}>{count}</button>
}
function App() {
  return <Counter />
}

توجه کنید که تمام چیز‌هایی که در اینجا در موردشان صحبت می‌کنم، در class componentها نیز قابل استفاده هستند. هوک‌ها تنها کار را کمی راحت‌تر می‌کنند. (مخصوصا کانتکست که کمی بعد به سراغ آن می‌رویم.)

class Counter extends React.Component {
  state = {count: 0}
  increment = () => this.setState(({count}) => ({count: count + 1}))
  render() {
    return <button onClick={this.increment}>{this.state.count}</button>
  }
}

ما هم‌اکنون یک المنت واحد که در آن State مدیریت می‌شود در یک کامپوننت واحد داریم. اما زمانی که می‌خواهیم Stateمان را بین کامپوننت‌های مختلف اشتراک‌گذاری کنیم چه؟ برای مثال اگر می‌خواستیم کار زیر را بکنیم، باید از چه روشی استفاده کنیم.

function CountDisplay() {
  // where does `count` come from?
  return <div>The current counter count is {count}</div>
}
function App() {
  return (
    <div>
      <CountDisplay />
      <Counter />
    </div>
  )
}

Count داخل <Counter /> مدیریت می‌شود. حالا ما به یک کتاب‌خانه مدیریت state‌ نیاز داریم تا به count در <CountDisplay /> دسترسی داشته باشیم و آن را در <Counter /> آپدیت کنیم.

جواب به این سوال به اندازه‌ی ریکت قدمت دارد. و از زمانی که من یادم می‌آید در مستندات ریکت موجود بود. اینجا Lifting state up، جواب مورد نظر به این مشکل مدیریت State‌ در ریکت است. چگونه آن را اعمال کنیم؟

function Counter({count, onIncrementClick}) {
  return <button onClick={onIncrementClick}>{count}</button>
}
function CountDisplay({count}) {
  return <div>The current counter count is {count}</div>
}
function App() {
  const [count, setCount] = React.useState(0)
  const increment = () => setCount(c => c + 1)
  return (
    <div>
      <CountDisplay count={count} />
      <Counter count={count} onIncrementClick={increment} />
    </div>
  )
}

ما تازه تغییر داده‌ایم که مسيولیت stateمان با کیست؛و این واقعا سادست.ما می‌توانیم Stateمان را همین‌طور تا بالاترین نقطه‌ی برنامه‌مان Lift کنیم.

قبول. اما در رابطه با prop drilling چطور؟

این یک مشکل است که در واقع مدت زمان طولانی نیز راه‌حل داشت، اما تنها اخیرا آن راه‌حل رسمی بود. همان‌طور که گفتم، بسیاری از افراد به سمت react-redux رفتند زیرا؛ این مشکل را با مکانیزمی که به آن مراجعه خواهم کرد، حل کرد. بدون اینکه آن‌ها نسبت به اخطاری که در مستندات React وجود دارد، نگران باشند. اما هم‌اکنون، context یک روش رسمی React برای حل این مشکل است که می‌توانیم از آن استفاده کنیم. 

// src/count/count-context.js
import React from 'react'
const CountContext = React.createContext()
function useCount() {
  const context = React.useContext(CountContext)
  if (!context) {
    throw new Error(`useCount must be used within a CountProvider`)
  }
  return context
}
function CountProvider(props) {
  const [count, setCount] = React.useState(0)
  const value = React.useMemo(() => [count, setCount], [count])
  return <CountContext.Provider value={value} {...props} />
}
export {CountProvider, useCount}
// src/count/page.js
import React from 'react'
import {CountProvider, useCount} from './count-context'
function Counter() {
  const [count, setCount] = useCount()
  const increment = () => setCount(c => c + 1)
  return <button onClick={increment}>{count}</button>
}
function CountDisplay() {
  const [count] = useCount()
  return <div>The current counter count is {count}</div>
}
function CountPage() {
  return (
    <div>
      <CountProvider>
        <CountDisplay />
        <Counter />
      </CountProvider>
    </div>
  )
}

توجه کنید که: کد بالا فقط یک مثال است و من هرگز استفاده از context را برای موقعیت‌هایی مانند بالا توصیه نمی‌کنم. لطفا مقاله‌ی prop drilling را بخوانید تا بفهمید که چرا این قضیه حتما یک مشکل نیست و در بیشتر مواقع مطلوب است. خیلی زود سراغ context نروید.

و اما نکته جالب در مورد این روش: ما می‌توانیم تمام منطق برنامه‌‌ی خودمان برای آپدیت کردن استیت را در هوک useContext بگذاریم.

function useCount() {
  const context = React.useContext(CountContext)
  if (!context) {
    throw new Error(`useCount must be used within a CountProvider`)
  }
  const [count, setCount] = context
  const increment = () => setCount(c => c + 1)
  return {
    count,
    setCount,
    increment,
  }
}

و می‌توانیم به راحتی به جای useState از useReducer استفاده کنیم.

function countReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT': {
      return {count: state.count + 1}
    }
    default: {
      throw new Error(`Unsupported action type: ${action.type}`)
    }
  }
}
function CountProvider(props) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  const value = React.useMemo(() => [state, dispatch], [state])
  return <CountContext.Provider value={value} {...props} />
}
function useCount() {
  const context = React.useContext(CountContext)
  if (!context) {
    throw new Error(`useCount must be used within a CountProvider`)
  }
  const [state, dispatch] = context
  const increment = () => dispatch({type: 'INCREMENT'})
  return {
    state,
    dispatch,
    increment,
  }
}

این روش باعث زیاد شدن انعطاف‌پذیری و کمتر شدن پیچیدگی می‌شود. و اما حین استفاده از این روش، به نکات زیر توجه کنید

  1. تمام state برنامه‌ی شما نیاز نیست که داخل یک آبجکت باشد. همه چیز را منطقی جدا کنید.( مثلا اطلاعات یوزر حتما لازم نیست با notification ها در یک کانتکست باشد.) با این روش شما باید دو provider جدا داشته باشید.
  2. تمام state شما نیاز نیست که به طور global قابل دسترس باشد. State را تا جایی که ممکن است نزدیک به جایی که نیاز است نگه‌ دارید.

با استفاده از نکته دوم، برنامه‌ی شما می‌تواند چیزی شبیه به این باشد.

function App() {
  return (
    <ThemeProvider>
      <AuthenticationProvider>
        <Router>
          <Home path="/" />
          <About path="/about" />
          <UserPage path="/:userId" />
          <UserSettings path="/settings" />
          <Notifications path="/notifications" />
        </Router>
      </AuthenticationProvider>
    </ThemeProvider>
  )
}
function Notifications() {
  return (
    <NotificationsProvider>
      <NotificationsTab />
      <NotificationsTypeList />
      <NotificationsList />
    </NotificationsProvider>
  )
}
function UserPage({username}) {
  return (
    <UserProvider username={username}>
      <UserInfo />
      <UserNav />
      <UserActivity />
    </UserProvider>
  )
}
function UserSettings() {
  // this would be the associated hook for the AuthenticationProvider
  const {user} = useAuthenticatedUser()
}

توجه کنید که هر صفحه می‌تواند برای خودش یک provider جدا داشته باشد که اطلاعات مورد نیاز برای کامپوننت‌های فرزند خود را در آن نگه دارد. Code splitting برای این موارد نیز کار می‌کند. این که اطلاعات چگونه وارد هر provider می‌شوند بستگی به هوک‌هایی که آن provider استفاده می‌کند و چگونگی دریافت دیتا در برنامه‌ی شما دارد.

سخن پایانی

توجه کنید: ( این روش را شما می‌توانید با استفاده از کامپوننت‌های کلاسی نیز انجام دهید.) و نیازی نیست حتما از هوک‌ها استفاده کنید. هوک‌ها این روش را بسیار ساده‌تر می‌کنند اما شما می‌توانید این فلسفه را در ریکت ۱۵ نیز استفاده کنید. State‌ها را تا حد ممکن محلی نگه دارید و تنها زمانی که prop drilling‌ واقعا برای شما یک معظل بزرگ است از context استفاد کنید. انجام کار‌ها از این طریق، حفظ تعامل state را برای شما آسان‌تر می‌کند.

منبع

گردآوری و تالیف مهدی عقیقی

برنامه‌نویس وب، عاشق جاوااسکریپت و ریکت و لاراول :)