Kliensoldali webprogramozás

Middleware-ek.
Aszinkronitás kezelése.
Redux kiegészítések.
Állapotkezelő könyvtárak

Horváth Győző
Egyetemi docens
1117 Budapest, Pázmány Péter sétány 1/c., 2.408-as szoba
Tel: (1) 372-2500/8469
horvath.gyozo@inf.elte.hu

Ismétlés

React

  • Komponensek
  • state, props
  • Data down, action up
  • Kompozíció
  • Hooks

Állapottér

  • useState
  • Context
  • Redux
    • egyirányú adatfolyam
    • tiszta függvények
    • immutábilitás

Architektúra

  • Külső állapottér
  • egyirányú adatfolyam
  • Redux

Redux

Központi koncepciók

  • State: állapottér mint egyetlen objektum
  • Action: módosító parancsok mint objektumok
  • Action creator: Action-öket előállító tiszta függvény
  • Reducer: (state, action) → newState
  • Store: State + Dispatcher + Reducer

Redux koncepciók

State

{ /* ... */ }

Action

{ type: 'ADD_TODO', text: 'Go to swimming pool' }

Action creator

const addTodo = text => ({
  type: ADD_TODO,
  text
})

Redux koncepciók

Reducer

function reducer(state = initialState, action) {
  // Create the new state
  return state
}

Store

const store = createStore(reducer)

Három alapelv

  • Az állapottér egyetlen komplex objektum
    • csak adat
    • az alkalmazás adatleírása
  • Az állapottér csak olvasható
    • immutábilis
    • csak akción keresztül változtatható meg
  • A reducer tiszta függvény
    • tesztelhető
    • funkcionális paradigma

Redux gondolkodás

  • Redux-független állapottér
    • Állapottér átgondolása
    • Állapotátmenetek
    • Lekérdezések
    • Tesztelhető
  • Redux
    • actions + action creators
    • reducer(s)
    • store
    • CLI tesztelés
  • React
    • komponensek
    • selectors
    • dispatch actions

Redux middleware

Middleware

  • Redux funkcionalitásának bővítése
  • Middleware minta (~ Laravel, express, stb)
  • Külső dispatch és a reducer között van
  • Egy függvény
    • módosíthatja az action-t
    • elnyelheti
    • új action kiváltása
    • mellékhatáskezelés (aszinkronitás)
  • Lényeg: végén egy action objektum legyen

Middleware

Middleware

Redux middleware

const middleware1 = store => next => action => {
  return next(action)
}
import { createStore, applyMiddleware } from 'redux'
const store = createStore(
  rootReducer,
  applyMiddleware(middleware1, middleware2)
)

Middleware

Példa

// middlewares
const middleware1 = store => next => action => {
  console.log('middleware1', action, next)
  const result = next(action)
  console.log('middleware1', result)
  return 'alma'
}
const middleware2 = store => next => action => {
  // store.dispatch(makeTip('z'))
  console.log('middleware2', action, next)
  const result = next(action)
  console.log('middleware2', result)
  return 'korte'
}

// store
const store = createStore(rootReducer, getInitialState(), 
  applyMiddleware(middleware1, middleware2))

Logger

const logger = store => next => action => {
  console.log('dispatching', action)
  let result = next(action)
  console.log('next state', store.getState())
  return result
}

További példák

const confirmationMiddleware = store => next => action => {
  if (action.shouldConfirm) {
    if (confirm('Are you sure?')) {
      next(action)
    }
  } else {
    next(action)
  }
}

const timeoutScheduler = store => next => action => {
  if (!action.meta || !action.meta.delay) {
    return next(action)
  }

  const timeoutId = setTimeout(() => next(action), action.meta.delay)

  return function cancel() {
    clearTimeout(timeoutId)
  }
}

const vanillaPromise = store => next => action => {
  if (typeof action.then !== 'function') {
    return next(action)
  }

  return Promise.resolve(action).then(store.dispatch)
}

const thunk = store => next => action =>
  typeof action === 'function'
    ? action(store.dispatch, store.getState)
    : next(action)

Aszinkronitás kezelése

Aszinkron API-k

  • Eseménykezelők
  • Időzítők
  • XMLHttpRequest, fetch
  • IndexedDB
  • időintenzív feladatok
  • függvénykönyvtárak

Aszinkronitás kezelése

Callback

setTimeout(() => {
  // run after 1s
}, 1000)
const xhr = new XMLHttpRequest();
xhr.addEventListener('load', function () {
  // run after response is received
});
xhr.open('GET', `http://www.omdbapi.com/?t=${title}&apikey=<key>`);
xhr.send(null);

Aszinkronitás kezelése

Promise, Promise.all(), Promise.race(), Promise.resolve()

const promise = new Promise((resolve, reject) => {
  resolve(value)
})
promise.then(value => {
  // do something with value
})
fetch(`http://www.omdbapi.com/?t=${title}&apikey=2dd0dbee`)
  .then(response => response.json())
  .then(dataAsJson => {
    // do something with dataAsJson
  })

Aszinkronitás kezelése

async-await függvények

async function () {
  const value = await anAsyncApi()
  return value
}
async function getPoster() {
  const title = document.querySelector('input').value
  const response = await fetch(`http://www.omdbapi.com/?t=${title}&apikey=2dd0dbee`)
  const dataAsJson = await response.json()
  // do something with dataAsJson
}

Aszinkron API

const delay = ms => new Promise((resolve, reject) => setTimeout(() => resolve(), ms))

class StorageApi {
  constructor(items = [], ms = 0) {
    this.ms = ms
    this.items = items
  }
  async getItems() {
    await delay(this.ms)
    return this.items
  }
  async addItem(item) {
    await delay(this.ms)
    const id = this.items.length
    item.id = id
    this.items.push(item)
    return item
  }
}
const api = new StorageApi([{id: 0, fruit: 'apple'}, {id: 1, fruit: 'pear'}], 1000)

Hooks

function App() {
  const [items, setItems] = useState([])
  useEffect(() => {   // 😀
    const getAll = async () => setItems(await api.getItems())
    getAll()
  }, [])
  return (
    <ul>
      {items.map(({id, fruit}) => 
        <li key={id}>{fruit}</li>
      )}
    </ul>
  )
}
function App() {
  const [items, setItems] = useState([])
  api.getItems().then(items => setItems(items)) // 😕 fetches on every rerender, cannot rerender on prop change
  return (
    <ul>
      {items.map(({id, fruit}) => 
        <li key={id}>{fruit}</li>
      )}
    </ul>
  )
}

Hooks

function App() {
  const [items, setItems] = useState([])
  const [value, setValue] = useState('plum')
  useEffect(() => {
    const getAll = async () => setItems([...await api.getItems()])
    getAll()
  }, [])

  const handleSubmit = async e => {
    e.preventDefault()
    const newItem = await api.addItem({fruit: value})
    setItems(items.concat(newItem))
  }

  return (
    <div style={{backgroundColor: randomColor(), padding: '10px'}}>
      <form onSubmit={handleSubmit}>
        <input type="text" value={value} onChange={e => setValue(e.target.value)} />
      </form>
      <ul>
        {items.map(({id, fruit}) => 
          <li key={id}>{fruit}</li>
        )}
      </ul>
    </div>
  )
}

Hooks + context

const useItemsService = () => {
  const [items, setItems] = useState([])
  useEffect(() => {
    const getAll = async () => setItems([...await api.getItems()])
    getAll()
  }, [])
  const addNewItem = async item => {
    const newItem = await api.addItem(item)
    setItems(items => items.concat(newItem))
  }
  return { items, addNewItem }
}

const ItemsContext = React.createContext()
const ItemsProvider = ({children}) => {
  const itemsService = useItemsService()
  return <ItemsContext.Provider value={itemsService}>{children}</ItemsContext.Provider>
}

function App() {
  const {items, addNewItem} = useContext(ItemsContext)
  const [value, setValue] = useState('plum')
  
  const handleSubmit = async e => {
    e.preventDefault()
    await addNewItem({fruit: value})
  }

  return (
    <div style={{backgroundColor: randomColor(), padding: '10px'}}>
      <form onSubmit={handleSubmit}>
        <input type="text" value={value} onChange={e => setValue(e.target.value)} />
      </form>
      <ul>
        {items.map(({id, fruit}) => 
          <li key={id}>{fruit}</li>
        )}
      </ul>
    </div>
  )
}

ReactDOM.render(
  <ItemsProvider>
    <App />
  </ItemsProvider>,
  document.getElementById('root')
)

Redux

Store

// State
const initialState = {
  isFetching: false,
  items: [],
}

// Action types
const FETCH_ITEMS_REQUEST = 'FETCH_ITEMS_REQUEST'
const FETCH_ITEMS_SUCCESS = 'FETCH_ITEMS_SUCCESS'
const SAVE_ITEM_REQUEST = 'SAVE_ITEM_REQUEST'
const SAVE_ITEM_SUCCESS = 'SAVE_ITEM_SUCCESS'

// Action creators
const fetchItemsRequest = () => ({
  type: FETCH_ITEMS_REQUEST
})
const fetchItemsSuccess = (items) => ({
  type: FETCH_ITEMS_SUCCESS,
  items
})
const saveItemRequest = () => ({
  type: SAVE_ITEM_REQUEST
})
const saveItemSuccess = (item) => ({
  type: SAVE_ITEM_SUCCESS,
  item
})

// Reducer
const rootReducer = (state = initialState, action) => {
  switch (action.type) {
    case FETCH_ITEMS_REQUEST:
    case SAVE_ITEM_REQUEST:
      return {...state, isFetching: true}
    case FETCH_ITEMS_SUCCESS:
      return {...state, isFetching: false, items: [...action.items]}
    case SAVE_ITEM_SUCCESS:
      return {...state, isFetching: false, items: state.items.concat(action.item)}
    default:
      return state
  }
}

// Store + middlewares
const loggerMiddleware = createLogger()
const store = createStore(
  rootReducer,
  applyMiddleware(
    thunkMiddleware, // lets us dispatch() functions
    loggerMiddleware // neat middleware that logs actions
  )
)

// Synchronous test
store.dispatch(fetchItemsRequest())
store.dispatch(fetchItemsSuccess([{id: 0, fruit: 'apple'}, {id: 1, fruit: 'pear'}]))
store.dispatch(saveItemRequest({fruit: 'plum'}))
store.dispatch(saveItemSuccess({fruit: 'plum', id: 2}))

Redux

  • Szinkron folyamat
    • akció
    • reducer
    • új állapot
  • Aszinkronitás támogatása middlewaren keresztül lehetséges
  • Az aszinkron folyamat állapota tükröződhet az állapottéren

Redux

Szinkron hívás, aszinkronitás a komponensben

function App() {
  const items = useSelector(state => state.items)
  const dispatch = useDispatch()
  const [value, setValue] = useState('plum')

  useEffect(() => {
    const getAll = async () => {
      dispatch(fetchItemsRequest())
      const items = await api.getItems()
      dispatch(fetchItemsSuccess(items))
    }
    getAll()
  }, [dispatch])

  const handleSubmit = async e => {
    e.preventDefault()
    dispatch(saveItemRequest())
    const item = await api.addItem({fruit: e.target.value})
    dispatch(saveItemSuccess(item))
  }

  return (
    <div style={{backgroundColor: randomColor(), padding: '10px'}}>
      <form onSubmit={handleSubmit}>
        <input type="text" value={value} onChange={e => setValue(e.target.value)} />
      </form>
      <ul>
        {items.map(({id, fruit}) => 
          <li key={id}>{fruit}</li>
        )}
      </ul>
    </div>
  )
}

Redux

Szinkron hívás, külön függvények

const getAll = async dispatch => {
  dispatch(fetchItemsRequest())
  const items = await api.getItems()
  dispatch(fetchItemsSuccess(items))
}
const save = async (dispatch, value) => {
  dispatch(saveItemRequest())
  const item = await api.addItem({fruit: value})
  dispatch(saveItemSuccess(item))
}

function App() {
  const items = useSelector(state => state.items)
  const dispatch = useDispatch()
  const [value, setValue] = useState('plum')

  useEffect(() => {
    getAll(dispatch)
  }, [dispatch])

  const handleSubmit = e => {
    e.preventDefault()
    save(dispatch, e.target.value)
  }

  return (
    <div style={{backgroundColor: randomColor(), padding: '10px'}}>
      <form onSubmit={handleSubmit}>
        <input type="text" value={value} onChange={e => setValue(e.target.value)} />
      </form>
      <ul>
        {items.map(({id, fruit}) => 
          <li key={id}>{fruit}</li>
        )}
      </ul>
    </div>
  )
}

Redux

Thunk middleware, aszinkron akciók

// Async action creators
const getAll = () => async dispatch => {
  dispatch(fetchItemsRequest())
  const items = await api.getItems()
  dispatch(fetchItemsSuccess(items))
}
const save = value => async dispatch => {
  dispatch(saveItemRequest())
  const item = await api.addItem({fruit: value})
  dispatch(saveItemSuccess(item))
}

function App() {
  const items = useSelector(state => state.items)
  const isFetching = useSelector(state => state.isFetching)
  const dispatch = useDispatch()
  const [value, setValue] = useState('plum')

  useEffect(() => {
    dispatch(getAll())
  }, [dispatch])

  const handleSubmit = e => {
    e.preventDefault()
    dispatch(save(value))
  }

  return (
    <div style={{backgroundColor: randomColor(), padding: '10px', border: `${isFetching ? 5 : 0}px solid red`}}>
      <form onSubmit={handleSubmit}>
        <input type="text" value={value} onChange={e => setValue(e.target.value)} />
      </form>
      <ul>
        {items.map(({id, fruit}) => 
          <li key={id}>{fruit}</li>
        )}
      </ul>
    </div>
  )
}

Thunk middleware

Függvény akciók

npm install --save redux-thunk
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/index';

const store = createStore(rootReducer, applyMiddleware(thunk));
function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => (next) => (action) => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }
    return next(action);
  };
}

Aszinkron akciók

// szinkron
const fetchItemsRequest = () => ({
  type: FETCH_ITEMS_REQUEST
})
// aszinkron
const getAll = () => async dispatch => {
  dispatch(fetchItemsRequest())
  const items = await api.getItems()
  dispatch(fetchItemsSuccess(items))
}
// aszinkron
const getAll = () => dispatch => {
  dispatch(fetchItemsRequest())
  api.getItems().then(items => dispatch(fetchItemsSuccess(items)))
}

Szinkron vs aszinkron

const INCREMENT_COUNTER = 'INCREMENT_COUNTER';

function increment() {
  return {
    type: INCREMENT_COUNTER,
  };
}
function incrementAsync() {
  return (dispatch) => {
    setTimeout(() => {
      dispatch(increment());
    }, 1000);
  };
}

Thunkban thunk

function fetchPosts(subreddit) {
  return dispatch => {
    dispatch(requestPosts(subreddit))
    return fetch(`https://www.reddit.com/r/${subreddit}.json`)
      .then(response => response.json())
      .then(json => dispatch(receivePosts(subreddit, json)))
  }
}
function shouldFetchPosts(state, subreddit) {
  return true
}
export function fetchPostsIfNeeded(subreddit) {
  return (dispatch, getState) => {
    if (shouldFetchPosts(getState(), subreddit)) {
      // Dispatch a thunk from thunk!
      return dispatch(fetchPosts(subreddit))
    } else {
      // Let the calling code know there's nothing to wait for.
      return Promise.resolve()
    }
  }
}

Összegezve

  • Aszinkronitás kezelés middlewareben
    • redux-thunk
    • redux-promise
    • redux-promise-middleware
    • redux-saga
    • redux-observable
  • Hívó kódnak nem is kell tudnia, hogy aszinkron az akció

Aszinkronitás Reduxban

Redux kiegészítések

Állapottér normalizálása

Egymásba ágyazott információk

const blogPosts = [
  {
    id: 'post1',
    author: { username: 'user1', name: 'User 1' },
    body: '......',
    comments: [
      {
        id: 'comment1',
        author: { username: 'user2', name: 'User 2' },
        comment: '.....'
      },
      {
        id: 'comment2',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      }
    ]
  },
  {
    id: 'post2',
    author: { username: 'user2', name: 'User 2' },
    body: '......',
    comments: [
      {
        id: 'comment3',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      },
      {
        id: 'comment4',
        author: { username: 'user1', name: 'User 1' },
        comment: '.....'
      },
      {
        id: 'comment5',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      }
    ]
  }
  // and repeat many times
]

Normalizálás

Normalizált struktúra, “táblázatok”

{
    posts : {
        byId : {
            "post1" : {
                id : "post1",
                author : "user1",
                body : "......",
                comments : ["comment1", "comment2"]
            },
            "post2" : {
                id : "post2",
                author : "user2",
                body : "......",
                comments : ["comment3", "comment4", "comment5"]
            }
        },
        allIds : ["post1", "post2"]
    },
    comments : {
        byId : {
            "comment1" : {
                id : "comment1",
                author : "user2",
                comment : ".....",
            },
            "comment2" : {
                id : "comment2",
                author : "user3",
                comment : ".....",
            },
            "comment3" : {
                id : "comment3",
                author : "user3",
                comment : ".....",
            },
            "comment4" : {
                id : "comment4",
                author : "user1",
                comment : ".....",
            },
            "comment5" : {
                id : "comment5",
                author : "user3",
                comment : ".....",
            },
        },
        allIds : ["comment1", "comment2", "comment3", "commment4", "comment5"]
    },
    users : {
        byId : {
            "user1" : {
                username : "user1",
                name : "User 1",
            },
            "user2" : {
                username : "user2",
                name : "User 2",
            },
            "user3" : {
                username : "user3",
                name : "User 3",
            }
        },
        allIds : ["user1", "user2", "user3"]
    }
}

Normalizálás

{
    simpleDomainData1: {....},
    simpleDomainData2: {....},
    entities : {
        entityType1 : {....},
        entityType2 : {....}
    },
    ui : {
        uiSection1 : {....},
        uiSection2 : {....}
    }
}

Immutábilitás

const { Map } = require('immutable');
const map1 = Map({ a: 1, b: 2, c: 3 });
const map2 = map1.set('b', 50);
map1.get('b') + " vs. " + map2.get('b'); // 2 vs. 50
import produce from "immer"
const baseState = [{
  todo: "Learn typescript",
  done: true
}, {
  todo: "Try immer",
  done: false
}]
const nextState = produce(baseState, draftState => {
    draftState.push({todo: "Tweet about it"})
    draftState[1].done = true
})

Redux toolkit

Tipikus aszinkron lekérés

// First, define the reducer and action creators via `createSlice`
const usersSlice = createSlice({
  name: 'users',
  initialState: {
    loading: 'idle',
    users: [],
  },
  reducers: {
    usersLoading(state, action) {
      // Use a "state machine" approach for loading state instead of booleans
      if (state.loading === 'idle') {
        state.loading = 'pending'
      }
    },
    usersReceived(state, action) {
      if (state.loading === 'pending') {
        state.loading = 'idle'
        state.users = action.payload
      }
    },
  },
})

// Destructure and export the plain action creators
export const { usersLoading, usersReceived } = usersSlice.actions

// Define a thunk that dispatches those action creators
const fetchUsers = () => async (dispatch) => {
  dispatch(usersLoading())
  const response = await usersAPI.fetchAll()
  dispatch(usersReceived(response.data))
}

Redux ajánlása

const getRepoDetailsStarted = () => ({
  type: "repoDetails/fetchStarted"
})
const getRepoDetailsSuccess = (repoDetails) => ({
  type: "repoDetails/fetchSucceeded",
  payload: repoDetails
})
const getRepoDetailsFailed = (error) => ({
  type: "repoDetails/fetchFailed",
  error
})
const fetchIssuesCount = (org, repo) => async dispatch => {
  dispatch(getRepoDetailsStarted())
  try {
    const repoDetails = await getRepoDetails(org, repo)
    dispatch(getRepoDetailsSuccess(repoDetails))
  } catch (err) {
    dispatch(getRepoDetailsFailed(err.toString()))
  }
}

createAsyncThunk

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'

// First, create the thunk
const fetchUserById = createAsyncThunk(
  'users/fetchByIdStatus',
  async (userId, thunkAPI) => {
    const response = await userAPI.fetchById(userId)
    return response.data
  }
)

// Then, handle actions in your reducers:
const usersSlice = createSlice({
  name: 'users',
  initialState: { entities: [], loading: 'idle' },
  reducers: {
    // standard reducer logic, with auto-generated action types per reducer
  },
  extraReducers: (builder) => {
    // Add reducers for additional action types here, and handle loading state as needed
    builder.addCase(fetchUserById.fulfilled, (state, action) => {
      // Add user to the state array
      state.entities.push(action.payload)
    })
  },
})

// Later, dispatch the thunk as needed in the app
dispatch(fetchUserById(123))

Normalizált adatok használata

createEntityAdapter

import {
  createEntityAdapter,
  createSlice,
  configureStore,
} from '@reduxjs/toolkit'

const booksAdapter = createEntityAdapter()

const booksSlice = createSlice({
  name: 'books',
  initialState: booksAdapter.getInitialState(),
  reducers: {
    bookAdded: booksAdapter.addOne,
    booksReceived(state, action) {
      booksAdapter.setAll(state, action.payload.books)
    },
  },
})

const store = configureStore({
  reducer: {
    books: booksSlice.reducer,
  },
})

console.log(store.getState().books)
// { ids: [], entities: {} }

// Can create a set of memoized selectors based on the location of this entity state
const booksSelectors = booksAdapter.getSelectors((state) => state.books)

// And then use the selectors to retrieve values
const allBooks = booksSelectors.selectAll(store.getState())

createEntityAdapter

import {
  createSlice,
  createAsyncThunk,
  createEntityAdapter,
} from '@reduxjs/toolkit'
import userAPI from './userAPI'

export const fetchUsers = createAsyncThunk('users/fetchAll', async () => {
  const response = await userAPI.fetchAll()
  // In this case, `response.data` would be:
  // [{id: 1, first_name: 'Example', last_name: 'User'}]
  return response.data
})

export const updateUser = createAsyncThunk('users/updateOne', async (arg) => {
  const response = await userAPI.updateUser(arg)
  // In this case, `response.data` would be:
  // { id: 1, first_name: 'Example', last_name: 'UpdatedLastName'}
  return response.data
})

export const usersAdapter = createEntityAdapter()

// By default, `createEntityAdapter` gives you `{ ids: [], entities: {} }`.
// If you want to track 'loading' or other keys, you would initialize them here:
// `getInitialState({ loading: false, activeRequestId: null })`
const initialState = usersAdapter.getInitialState()

export const slice = createSlice({
  name: 'users',
  initialState,
  reducers: {
    removeUser: usersAdapter.removeOne,
  },
  extraReducers: (builder) => {
    builder.addCase(fetchUsers.fulfilled, usersAdapter.upsertMany)
    builder.addCase(updateUser.fulfilled, (state, { payload }) => {
      const { id, ...changes } = payload
      usersAdapter.updateOne(state, { id, changes })
    })
  },
})

const reducer = slice.reducer
export default reducer

export const { removeUser } = slice.actions

Server state vs global state

  • Server state
    • máshol van tárolva
    • aszinkron API kell az eléréséhez
    • megváltozhat a kliens tudtán kívül
    • elévülhet
  • Nem a globális állapot része
  • Kihívások
    • cache-elés
    • kérések összevonása
    • elévülés kezelése
    • optimalizációk

Server state vs global state

const globalState = {
  projects,
  teams,
  tasks,
  users,
  themeMode,
  sidebarStatus,
}

=>

const serverState = {
  projects,
  teams,
  tasks,
  users,
}
const globalState = {
  themeMode,
  sidebarStatus,
}

RTK Query

Definíció

// Need to use the React-specific entry point to import createApi
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

// Define a service using a base URL and expected endpoints
export const pokemonApi = createApi({
  reducerPath: 'pokemonApi',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
  endpoints: (builder) => ({
    getPokemonByName: builder.query({
      query: (name) => `pokemon/${name}`,
    }),
  }),
})

// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = pokemonApi

RTK Query

Regisztráció

import { configureStore } from '@reduxjs/toolkit'
// Or from '@reduxjs/toolkit/query/react'
import { setupListeners } from '@reduxjs/toolkit/query'
import { pokemonApi } from './services/pokemon'

export const store = configureStore({
  reducer: {
    // Add the generated reducer as a specific top-level slice
    [pokemonApi.reducerPath]: pokemonApi.reducer,
  },
  // Adding the api middleware enables caching, invalidation, polling,
  // and other useful features of `rtk-query`.
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(pokemonApi.middleware),
})

// optional, but required for refetchOnFocus/refetchOnReconnect behaviors
// see `setupListeners` docs - takes an optional callback as the 2nd arg for customization
setupListeners(store.dispatch)

RTK Query

Használat

import * as React from 'react'
import { useGetPokemonByNameQuery } from './services/pokemon'

export default function App() {
  // Using a query hook automatically fetches data and returns query values
  const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')
  // Individual hooks are also accessible under the generated endpoints:
  // const { data, error, isLoading } = pokemonApi.endpoints.getPokemonByName.useQuery('bulbasaur')

  return (
    <div className="App">
      {error ? (
        <>Oh no, there was an error</>
      ) : isLoading ? (
        <>Loading...</>
      ) : data ? (
        <>
          <h3>{data.species.name}</h3>
          <img src={data.sprites.front_shiny} alt={data.species.name} />
        </>
      ) : null}
    </div>
  )
}

RTK Query példa

import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

const BASE_URL = "http://localhost:3030/";

const apiSlice = createApi({
  reducerPath: "playlistsApi",
  baseQuery: fetchBaseQuery({
    baseUrl: BASE_URL,
  }),
  tagTypes: ["Playlist", "Track"],
  endpoints: (build) => ({
    getPlaylists: build.query({
      query: (id) => ({ url: "playlists" }),
      transformResponse: (response) => response.data,
      providesTags: () => ["Playlist"],
    }),
    postPlaylist: build.mutation({
      query: (body) => ({
        url: "playlists",
        method: "POST",
        body: body,
      }),
      transformResponse: (response) => response.data,
      invalidatesTags: ["Playlist"],
    }),
    putPlaylist: build.mutation({
      query: ({ id, ...body }) => ({
        url: `playlists/${id}`,
        method: "PUT",
        body: body,
      }),
      transformResponse: (response) => response.data,
      invalidatesTags: ["Playlist"],
    }),

    getTracks: build.query({
      query: (id) => ({ url: "tracks" }),
      transformResponse: (response) => response.data,
      providesTags: () => ["Track"],
    }),
    postTrack: build.mutation({
      query: (body) => ({
        url: "tracks",
        method: "POST",
        body: body,
      }),
      transformResponse: (response) => response.data,
      invalidatesTags: ["Track"],
    }),
    putTrack: build.mutation({
      query: ({ id, ...body }) => ({
        url: `tracks/${id}`,
        method: "PUT",
        body: body,
      }),
      transformResponse: (response) => response.data,
      invalidatesTags: ["Playlist", "Track"],
    }),
    deleteTrack: build.mutation({
      query: (id) => ({
        url: `tracks/${id}`,
        method: "DELETE",
      }),
      transformResponse: (response) => response.data,
      invalidatesTags: ["Playlist", "Track"],
    }),
  }),
});

export const {
  useGetPlaylistsQuery,
  usePostPlaylistMutation,
  usePutPlaylistMutation,

  useGetTracksQuery,
  usePostTrackMutation,
  usePutTrackMutation,
  useDeleteTrackMutation,
} = apiSlice;

RTK Query példa

Használat

const Tracks = () => {
  const { data: tracks = [] } = useGetTracksQuery();
  const [postTrack] = usePostTrackMutation();
  const [putTrack] = usePutTrackMutation();
  const [deleteTrack] = useDeleteTrackMutation();

  const handleSubmit = (values) => {
    if (editedTrack) {
      putTrack({ ...editedTrack, ...values });
    } else {
      postTrack(values);
    }
  };

  return (/* ... */);
};

További állapottérkezelő könyvtárak

László Tamás diplomamunkája alapján

Történetileg

  • React state
  • (Flux)/Redux
  • MobX
  • useState/useReducer/Context
  • Alternatívák
    • Zustand (module state)
    • Jotai (React state)
    • Valtio (module state, proxy)
    • react-tracked (React state, proxy)

Állapotkezelő könyvtárak

  • Redux
  • MobX
  • MobX-State-Tree
  • XState

MobX

  • OOP-s megközelítés
  • Reaktív programozás (TFRP)
    • observables, observers
    • computed property-k

MobX

import { observable, action } from "mobx";
import { observer } from "mobx-react-lite";
import CounterProvider, { CounterContext } from "./CounterContext";

class CounterStore {
  @observable counter = 0;
  @action.bound increaseCounter() {
    this.counter += 1;
  }
}

const counterStore = new CounterStore();
export const CounterContext = React.createContext();
export default function CounterProvider({ children }) {
  return (
    <CounterContext.Provider value={counterStore}>
      {children}
    </CounterContext.Provider>
  );
}

function Button() {
  const { counter, increaseCounter } = React.useContext(CounterContext);
  return <button onClick={increaseCounter}>Clicked {counter} times</button>;
}

const ObservedButton = observer(Button);

const Application = (
  <CounterProvider>
    <ObservedButton />
  </CounterProvider>
);

MobX-State-Tree

  • Redux:
    • immutábilitás: kiszámíthatóság, nyomon követhetőség
  • MobX
    • mutábilitás: egyszerűség
    • megfigyelhetőség, reaktivitás
  • kötött formátum
const Store = types
  .model("Store", {
    counter: 0
  })
  .actions(self => ({
    increaseCounter() {
      self.counter += 1;
    }
  }));
const store = Store.create({});

XState

  • Véges állapotú automaták az állapot kezelésére
  • állapotdiagramok
  • actor modell: üzenetek küldése és fogadása

import { Machine } from 'xstate';

const lightMachine = Machine({
  id: 'light',
  initial: 'green',
  states: {
    green: {
      on: {
        TIMER: 'yellow'
      }
    },
    yellow: {
      on: {
        TIMER: 'red'
      }
    },
    red: {
      on: {
        TIMER: 'green'
      }
    }
  }
});

const currentState = 'green';
const nextState = lightMachine.transition(currentState, 'TIMER').value;
// => 'yellow'

Végszó

  • Aszinkronitás kezelése thunk middleware-rel
  • Többféle filozófiájú állapotkezelő könyvtár van
    • Redux
    • MobX
    • MobX-State-Tree
    • XState