Units
Motivation
Effector documentation gives very good motivation, why it should be used:
Effector offers the possibility to describe the business logic in the same language as the product development team communicates, using basic primitives: Event, Store, Effect respectively. At the same time, the UI logic remains the responsibility of the framework. Let each framework solve its task as efficiently as possible.
Ideally, all business logic should be implemented in independence from UI.
Good/bad drawing
✅ Good
- Predictable renders
- Not a matter of performance only, but maintainability
- More representative structure (code structure is more close to business logic)
❌ Not good
- Sometimes structure could be tricky and take refactoring
- Need to think in other way that we used to
Business task
- We have cheat detection mode
- Cheat detection can be activated only in exam mode, when it is active, and when cheat detection mode is enabled in exam settings.
- Cheat considered as detected when user tries to open another tab or window.
- When cheat detected we should show a warning message to the user.
- Teacher can manually deactivate cheat detection
Model first
Create basic model of exercises:
//creating effect for fetching exercise
const fetchExerciseFx = createEffect(() => {
return fetch('https://api.example.com/exercise')
})
//creating store for exercise
const $currentExercise = createStore(null)
.on(fetchExerciseFx.doneData, (_, exercise) => exercise)
Creating exam mode model:
const $isExamMode = $currentExercise
.map(exercise => exercise.type === 'exam')
const paused = createEvent()
const started = createEvent()
const finished = createEvent()
const statusChanged = createEvent()
const $examStatus = createStore('not-started')
.on(statusChanged, (_, status) => status)
.on(started, () => 'started')
.on(paused, () => 'paused')
.on(finished, () => 'finished')
Cheat detection model:
const manuallyDisabled = createEvent()
const $isManuallyDisabled = restore(manuallyDisabled, false)
const $isCheatDetectionActive = combine({
exercise: $currentExercise,
examStatus: $examStatus,
isManuallyDisabled: $isManuallyDisabled
}, ({exercise, examStatus, isManuallyDisabled }) => {
return exercise.type === 'exam'
&& exercise.cheatDetectionEnabled
&& examStatus === 'started'
&& !isManuallyDisabled
})
Changing tabs model (converting window events to effector events):
const tabBlurred = createEvent()
window.addEventListener('blur', () => tabBlurred())
//We can subscribe to window evenent on the very top level of our app
//We dont need to think about subscription,
//because top level runs only once
//From now on we can use tabChanged for anything if we need,
//not only in context of cheat detection
Getting back to cheat detection model:
const cheatDetected = createEvent()
const $detectedCheat = restore(cheatDetected, null)
//✅ Bind tab blurred with isActiveStore
sample({
source: $isCheatDetectionActive,
clock: tabBlurred,
filter: (isCheatDetectionEnabled) => isCheatDetectionEnabled,
fn: () => CheatType.LEAVING_BROWSER_OR_TAB,
target: cheatDetected
})
//✅ Also good option for binding
sample({
source: $isCheatDetectionActive,
clock: tabBlurred,
fn: (isCheatDetectionEnabled) => isCheatDetectionEnabled,
}).watch((isActive) => {
if(!isActive) return
cheatDetected(CheatType.LEAVING_BROWSER_OR_TAB)
})
New requirements
- when cheat is detected we want to send a message to a teacher
- after we sent a message, we should pause the exam. At the same time, we should disable cheat mode right after it was detected
- cheat considered as detected when complexity of exercise is more than 2 and user pressed hint button
Imagine we have user data store:
const $userData = createStore({name: 'Anton', teacherId: 234})
We want to sent data about cheat detection to a specific teacher, so we need to obtain their id and do request:
❌Short example how it is done with mixed logic
function reportToTeacher(teacherId, detectedCheat){
return fecth('https://api.example.com/report', {
method: 'POST',
body: JSON.stringify({teacherId, detectedCheat})
})
}
const Component = () => {
const teacherId = useSelector(state => state.userData.teacherId)
const reportToMyTeacher = (detectedCheat) => {
reportToTeacher(teacherId, detectedCheat)
}
return null
}
const reportAboutCheatToMyTeacher = attach({
source: $userData,
mapParams: (detectedCheat, userData) => JSON.stringify({
teacherId: userData.teacherId,
detectedCheat
}),
effect: createEffect((body) => {
return fecth('https://api.example.com/report', { method: 'POST', body })
}),
})
//Finishing exam after report sent
reportAboutCheatToMyTeacher.done.watch(() => {
exam.finished()
})
Let modify our cheat detection model:
//sending message to a teacher when cheat detected
cheatDetected.watch(reportAboutCheatToMyTeacher)
//We should disable cheat mode right after it was detected
//This way we disable cheat detection only for tabBlurred source, which is not really scalable
sample({
source: {
isCheatDetectionActive: $isCheatDetectionActive,
isReportSending: reportAboutCheatToMyTeacher.pending
},
clock: tabBlurred,
filter: ({isCheatDetectionEnabled, isReportSending}) => isCheatDetectionEnabled && !isReportSending,
fn: () => CheatType.LEAVING_BROWSER_OR_TAB,
target: cheatDetected
})
Disabling cheat detection more scalable way
//Disabling sending of request, so replacing
//cheatDetected.watch(reportAboutCheatToMyTeacher)
//with
sample({
source: reportAboutCheatToMyTeacher.pending,
clock: cheatDetected,
filter: isSending => !isSending,
fn: (_, cheatType) => cheatType,
target: reportAboutCheatToMyTeacher
})
//This way we disable any changes of store, when we already detected cheat
//and sending request depending on store, not an event
const cheatDetected = createEvent()
const $detectedCheat = createStore(null)
.on(cheatDetected, (store, cheatType) => {
if(store && cheatType) return
return cheatType
})
$detectedCheat.updates.watch((detectedCheat) => {
if(detectedCheat) reportAboutCheatToMyTeacher(detectedCheat)
})
Adding one more case when cheat is detected
cheat considered as detected when complexity of exercise is less than 3 and user pressed hint button
const hintButtonPressed = createEvent()
sample({
source: $currentExercise,
clock: hintButtonPressed,
filter: (exercise) => exercise.complexity < 3,
fn: () => CheatType.HINT_BUTTON,
target: cheatDetected
})
Last task
- We are implementing list of exercises
- when we get a list we should consider preferences of user (how many exercises per page they want to do etc.).
- Also, we should consider user's teacher, and user's status
- Response from server are paginated
❌ Don't
function fetchExercisesList({ preferences, teacherId, userStatus, page }){
return fecth(`https://api.example.com/exercises-list?prefs=${preferences}
&teacherId=${teacherId}
&status=${userStatus}
&page=${page}`
)
}
const Component = () => {
const userData = useUnit($userData)
const preferences = useUnit($userPreferences)
const requestData = useUnit($fetchExerciseData)
useEffect(() => {
fetchExercisesList({
preferences,
teacherId: userData.teacherId,
userStatus: userData.status,
page: 1 })
}, [])
return (
<List
onEndReach={() => {
if(requestData.page < requestData.totalPages){
fetchExercisesList({
preferences,
teacherId: userData.teacherId,
userStatus: userData.status,
page: requestData.page + 1
})
}
}}
/>
)
}
//Only request
const exercisesListRequestFx = createEffect(({preferences, teacherId, userStatus, page = 1}) => {
return fecth(`https://api.example.com/exercises-list?prefs=${preferences}
&teacherId=${teacherId}
&status=${userStatus}
&page=${page}`
)
})
//Request connected to a stores with needed data
const fetchExerciseList = attach({
source: {
preferences: $userPreferences,
userData: $userData,
},
mapParams: ({page = 1}, {preferences, userData}) => ({
preferences,
teacherId: userData.teacherId,
userStatus: userData.status,
page
}),
effect: exercisesListRequestFx
})
//updating pages data, needed for pagination, after request is done
const $requestSettings = createStore(null)
.on(exercisesListRequestFx.done, (_, {params, result}) => ({
page: params.page + 1,
totalPages: result.totalPages,
nextUrl: result.nextUrl
}))
const $exerciseList = createStore([]).on(fetchExerciseList.done, (state, {params, result}) => {
if(params.page === 1) return result.data
return [...state, ...result.data]
})
// From here there are two options how to implement
//✅ With event end reached
const pageEndReached = createEvent()
sample({
source: $requestSettings,
clock: pageEndReached,
filter: ({page, totalPages}) => page < totalPages,
fn: ({page}) => ({page}),
target: fetchExerciseList
})
//✅ With fetchNextPage effect
const fetchNextPage = attach({
source: {
pageSettings: $requestSettings,
isLoading: exercisesListRequestFx.pending
},
mapParams: (_, { pageSettings, isLoading }) => ({ pageSettings, isLoading }),
effect: createEffect(({ pageSettings, isLoading }) => {
if(isLoading || pageSettings.page >= pageSettings.totalPages) return null
return fetchExerciseList(pageSettings.page + 1)
})
})
const Component = () => {
const list = useUnit($exerciseList)
useEffect(() => {
fetchExerciseList()
}, [])
return (
<List data={list} onEndReach={fetchNextPage} /> // or pageEndReached
)
}
Pitfalls
Cycle requires
When import store from another file, and use instances from this file in the store we import from
Cycle references
When you have two stores that depend on each other, and they have data types
Units
Store
const $grade = createStore(5)
//❌ Don't skip first iteration this way
let isInitialized = false
$grade.watch((grade) => {
//will be called, first time right after watched and with every update
if(!isInitialized){
isInitialized = true
return
}
//do smth
})
//✅ Use Store.updates instead
$grade.updates.watch((grade) => {
//will be called, ONLY with updates
//do smth
})
//❌ Use app events, not "store manipulation" events
const setToken = creteEvent()
const resetToken = createEvent()
const $token = restore(setToken, '').reset(resetToken)
const setUserData = creteEvent()
const resetUserData = createEvent()
const $userData = restore(setUserData, null).reset(resetUserData)
const setAppData = creteEvent()
const resetAppData = createEvent()
const $appData = restore(setAppData, null).reset(resetAppData)
function logOut() {
resetToken()
resetUserData()
resetAppData()
}
//✅ Do
const logOut = createEvent()
const setToken = creteEvent()
const $token = restore(setToken, '').reset(logOut)
const setUserData = creteEvent()
const $userData = restore(setUserData, null).reset(logOut)
const setAppData = creteEvent()
const $appData = restore(setAppData, null).reset(logOut)
//❌ dont put put things that used directly for representation (e.g. styles, size)
const $buttonSize = createStore('big')
const $containHeight = createStore(100)
//✅ Create enums types and map them according to needed styles.
// Use states for defining styles inside UI components
const $grade = createStore(5)
function gradeToButtonSize(grade) {
if (grade > 4) return 'big'
if (grade > 2) return 'medium'
return 'small'
}
const Component = () => {
const buttonSize = useStoreMap($grade, gradeToButtonSize)
}
//❌ Don't use side effects inside on (any api calls, state changing etc)
const setFirstStore = creteEvent()
const $firstStore = createStore('big')
.on(setFirstStore, (state, payload) => {
const data = calcaulateData(payload, state)
fetch('https://api.example.com', {method: 'POST', body: JSON.stringify(data)})
return data
})
const setSecondStore = creteEvent()
const $secondStore = createStore()
.on(setSecondStore, (state, payload) => {
setFirstStore(payload)
return payload
})
//✅ Use watchers for side effects
$firstStore.updates.watch((payload) => {
fetch('https://api.example.com', {method: 'POST', body: JSON.stringify(payload)})
})
//✅ Use one event for server strore
const setSecondStore = creteEvent()
const setFirstStore = creteEvent()
const $firstStore = createStore('big')
.on(setFirstStore, (state, payload) => {
const data = calcaulateData(payload, state)
return data
})
.on(setSecondStore, (state, payload) => payload)
const setSecondStore = creteEvent()
.on(setSecondStore, (state, payload) => payload)
.on(setFirstStore, (state, payload) => payload)
Derived store
Store that depends on other stores. Cannot be changed directly with on/reset
Event
You can split one event to several events:
const examStateChanged = createEvent()
const started = examStateChanged.filter((state) => state === 'started')
const finished = examStateChanged.filter((state) => state === 'finished')
const paused = examStateChanged.filter((state) => state === 'paused')
Effect
Effects used for doing continuous actions. When event is just a signal, which doesn't care about the result, or what you are going to do with it. Effect is a thing that allow you to return result after action, and track completion of the last one.
Basic use
const fetchUserFx = createEffect(() => {
return fetch('https://api.example.com/user')
})
Effect's events, it sends, when it is on a different stages of completion task:
//Watching when effect called
fetchUserFx.watch(() => {})
//Watching when effect is done, allow us to obtain either
// result and params
fetchUserFx.done.watch(({result, params}) => {})
//Watching when effect is done, gives only result
fetchUserFx.doneData.watch((result) => {})
//Watching when effect is failed, gives error
fetchUserFx.fail.watch((error) => {})
//Watching when effect is complete (either failed or done)
fetchUserFx.finally.watch(() => {})
pending
How it is done with react state
const Component = () => {
const [isLoading, setIsLoading] = useState()
useEffect(() => {
setIsLoading(true)
fetch('https://api.example.com/user')
.finally(() => setIsLoading(false))
}, []);
}
const Component = () => {
const isLoading = useUnit(fetchUserFx.pending)
useEffect(() => {
fetchUserFx()
}, []);
}
So basically pending, is just a store, with boolean value.
There is a store inFlight that represents how many calls of the certain effect are in process right now
const MAXIMUM_CALLS = 5
const updateRequest = createEffect(() => {
if(updateRequest.inFlight.getState() > MAXIMUM_CALLS) return
return fetch('https://api.example.com/udpateRequest')
})
//or
const fetchUpdateRequest = createEffect(({ canProcess }) => {
return fetch('https://api.example.com/udpateRequest')
})
const updateRequest = attach({
source: fetchUpdateRequest.inFilght,
mapParams: (_, inFilght) => ({ canProcess: inFilght < MAXIMUM_CALLS }),
effect: fetchUpdateRequest,
})