Features
features is the folder where all components and logic related to app business logic live
The point that we keep all feature related code in specific feature folder:
Components (Views)models- business logic of featurehooks- component related business logic or hooks for connection views and modelshelpers- feature-related helpers (e.g. converters for server-client data)styles- any utils you need - so if you have some utils that are related to feature - you can name them as you want
The second point that we are trying to divide feature-related code into 2 main parts: Views (representation) and Models (logic)
Basically actions, reducers, thunks or sagas in redux represent model of feature. So for this approach it is possible to use any state manager you want and Redux official docs recommend use this approach for redux-based apps as well. But most of the examples will be with Effector
Also, I strongly recommend take a look at Redux style guide even if you are not going to use Redux since there a lot of useful concepts of structuring code that can be applied to any state manager.
View - Model
So as a model we understand business logic of feature. It can be:
- data (state) of feature
- data fetching
- data processing
Models flow may lives independently from React flow. So data "doesn't know" how it will be represented. Theoretically you can use the same model for web and react-native or even for another framework.
Effector example
import { createEvent, restore } from 'effector'
import { MyProfile } from '../../api/parts/users/types'
import { logOut } from '../auth/logOut/model'
export const setMyProfile = createEvent<MyProfile>()
export const updateProfile = createEvent<Partial<MyProfile>>()
export const $myProfile = restore(setMyProfile, null)
.on(updateProfile, (state, payload) => {
if (!state) return null
return { ...state, ...payload }
})
.reset(logOut)
export const loadMyProfile = createEffect(() => {
return api.users.me()
})
loadMyProfile.done.watch(({ result }) => {
setMyProfile(result)
})
const MyProfile = () => {
const isLoading = useStore(loadMyProfile.pending)
const myProfile = useStore($myProfile)
useEffect(() => {
loadMyProfile()
}, [])
}
Redux example
export enum MyProfileAction {
SET = 'MY_PROFILE_SET',
UPDATE = 'MY_PROFILE_UPDATE',
LOADING_START = 'MY_PROFILE_LOADING_START',
LOADING_ERROR = 'MY_PROFILE_LOADING_ERROR',
}
const initialState = {
data: null,
loading: false,
};
export function myProfileReducer(state = initialState, action: IAction<MyProfileAction>) {
switch (action.type) {
case MyProfileAction.SET:
return {
data: action.payload,
loading: false,
};
case MyProfileAction.UPDATE:
return { ...state, data: {...state.data, ...action.payload}};
case MyProfileAction.LOADING_START:
return {...state, loading: true };
case MyProfileAction.LOADING_ERROR:
return { ...state, loading: false };
default:
return state;
}
}
export function loadMyProfile() {
return async (dispatch: IDispatch<any>) => {
try {
dispatch({ type: MyProfileAction.LOADING_START })
const res = await api.users.me()
dispatch({ type: MyProfileAction.SET, payload: res.data });
} catch (e) {
dispatch({ type: MyProfileAction.LOADING_ERROR })
}
};
}
const MyProfile = () => {
const isLoading = useSelector((state: IRootState) => state.myProfile.loading)
const myProfile = useSelector((state: IRootState) => state.myProfile.data)
const dispatch = useDispatch()
useEffect(() => {
dispatch(loadMyProfile())
}, [])
}
We can try to go further and connect models between each other outside view (components) so out views will only represent data from models and will not contain any business logic. In example below we have feature edit profile that get current profile state and fill it into form
Example
...
export const setMyProfile = createEvent<MyProfile>()
export const updateProfile = createEvent<Partial<MyProfile>>()
export const $myProfile = restore(setMyProfile, null)
...
import { $myProfile } from '../model'
...
export const editProfileFormModel = createFormModel(editProfileFormSchema)
export const setEditProfileInitialData = attach({
source: $myProfile,
mapParams: (_: void, state: MyProfile | null) => state,
effect: createEffect((state: MyProfile | null) => {
if (!state) return
editProfileCountryModel.set(state.country)
editProfileFormModel.set(convertProfileBodyToEditForm(state))
}),
})
...
...
<>
{editProfileFormModel.mapKeys((name) => {
if (name === 'birthDate') {
return (
<DateField
offValidation
key={name}
label={t.birthDate}
displayDefaultDate
formModel={editProfileFormModel}
name={name}
style={fieldStyles}
validateOnBlur
maximumDate={new Date()}
/>
)
}
return (
<Field
key={name}
label={t[name]}
validateOnBlur
placeholder={t[name]}
formModel={editProfileFormModel}
name={name}
style={fieldStyles}
type={'default'}
/>
)
})}
</>
The same way we can work with requests:
Example
So instead of processing request data inside useEffect we can process it inside model files
import { prepareMyProfile } from './helpers'
import { setMyProfile } from './model'
export const meRequest = attach({
effect: api.users.me,
})
meRequest.done.watch(({ result }) => {
setMyProfile(prepareMyProfile(result))
})
Obviously not all data should be contained inside model. For example if you have list of articles and when user press the article item, you need to open article page with article id. And you know that lifecycle of article page is limited between opening and closing this specific page, so you may not need to store article data inside model, and you can keep it inside state.
Model hooks
In these cases consider to push your logic inside custom hooks, so your components will remain clean from logic. As example you have article page when you can like, save or follow author. So we can put this logic inside 'useArticleActions'
Example
So instead of processing request data inside useEffect we can process it inside model files
const artWork = drawing.data
const actions = useAtrWorkActions(artWork, drawing.update)
const pressHandler = useDoubleTap({
onDoublePress: actions.like,
})
if (!drawing.data && drawing.isLoading) {
return <Loader />
}
if (!drawing.data) return null
return (
<ScrollView bounces={false} style={styles.container}>
<UserCardPreview
onPress={(item) => {
if (item.id === myProfile?.id) {
return navigate(links.profileTab)
}
navigate(links.userProfile, { item })
}}
onPressFollow={actions.followAuthor}
item={drawing.data.author}
/>
<TouchableOpacity activeOpacity={1} onPress={pressHandler}>
<AutoHeightImage
image={{ uri: drawing.data.image_thumbnail }}
widthGenerator={() => SCREEN_CONTENT_WIDTH}
/>
</TouchableOpacity>
<ArtWorkInteractionPanel
item={drawing.data}
onPressLike={actions.toggleLike}
onPressSave={actions.save}
/>
</ScrollView>
)
export const useAtrWorkActions = (
itemData?: ArtWork | null,
updateItemState?: (item: Partial<ArtWork>) => void
) => {
const navigate = useNavigate()
const isAuth = useStore($isAuth)
const preHandling = useCallback(
(artData?: ArtWork | null) => {
if (!isAuth) return navigate(links.login)
return itemData || artData
},
[isAuth, itemData, navigate]
)
const updateGalleries = (data: Partial<ArtWork>) => {
Object.values(galleryListsModel).forEach((model) => {
model.updateItem(data)
})
}
const toggleLike = useCallback(
(artData?: ArtWork | null) => {
const item = preHandling(artData)
if (!item) return
const likesCount = item.is_liked ? item.likes - 1 : item.likes + 1
const newData = {
id: item.id,
is_liked: !item.is_liked,
likes: likesCount,
}
toggleLikeRequest(item).then(() => {
updateItemState?.(newData)
updateGalleries(newData)
})
},
[preHandling, updateItemState]
)
const like = useCallback(
(artData?: ArtWork | null) => {
const item = preHandling(artData)
if (!item) return
if (!item.is_liked) {
const newData = {
id: item.id,
is_liked: true,
likes: item.likes + 1,
}
api.arts.likePost(item.id).then(() => {
updateItemState?.(newData)
updateGalleries(newData)
})
}
},
[preHandling, updateItemState]
)
const save = (artData?: ArtWork | null) => {
const item = preHandling(artData)
if (!item) return
const newData = { id: item.id, is_saved: !item.is_saved }
toggleSaveRequest(item).then(() => {
updateItemState?.(newData)
updateGalleries(newData)
})
}
const followAuthor = (isFollowed: boolean) => {
const item = preHandling()
if (!item) return
const newData = {
id: item.id,
author: { ...item.author, is_followed: isFollowed },
}
updateItemState?.(newData)
updateGalleries(newData)
}
return { toggleLike, save, like, followAuthor }
}
Reusing of models
The one great point point of using models (and if we create models independently from components where they will use) is that you can reuse them.
Example of paginated list model
This is model for common paginated list with infinite scroll. There are common actions that all lists in this cases use: get, refresh, getNext, $store
export class PaginatedListModel<T, R, P> {
private readonly request
private readonly itemExtractor
private readonly nextPageGetter
private readonly nextPage = createStateModel<number | null>(1)
private readonly defaultProps
public readonly init = createEvent<P>()
public constructor({
request,
itemExtractor,
nextPageFilter,
defaultProps,
}: PaginatedListModelProps<T, R, P>) {
this.defaultProps = defaultProps
this.request = request
this.itemExtractor = itemExtractor
this.nextPageGetter = nextPageFilter
this.get.done.watch(this.resetListWith)
this.refresh.done.watch(this.resetListWith)
sample({
source: this.$items,
clock: this.getNextPageFx.done,
fn: (items, { result, params }) => {
if (!result || !params.page) return
const newItems = this.itemExtractor(result)
return {
newItems,
total: newItems.length + items.length,
page: params.page,
response: result,
}
},
}).watch(props => {
if (!props) return
const { newItems, ...nextPageProps } = props
this.addItems(newItems)
const nextPage = this.nextPageGetter(nextPageProps)
this.nextPage.set(nextPage)
})
sample({
source: this.$items,
clock: this.init,
filter: items => !items.length,
fn: (_, props) => props,
}).watch(this.getSync)
}
private readonly resetListWith = ({ result }: { result: R }) => {
const items = this.itemExtractor(result)
this.setItems(items)
const nextPage = this.nextPageGetter({
total: items.length,
response: result,
page: 1,
})
this.nextPage.set(nextPage)
}
public readonly get = createEffect((props: P) => {
return this.request({
...props,
page: 1,
})
})
public readonly $isLoading = this.get.pending
public readonly getSync = (props: P) => {
this.get(props).catch(noop)
}
private getNextPageFx = createEffect(
({ page, isLoading }: GetNextFxProps<P>) => {
if (!page || page === 1 || isLoading) return null
return this.request({
...this.defaultProps!,
page,
})
},
)
public readonly $isNextLoading = this.getNextPageFx.pending
public readonly getNext = attach({
source: {
page: this.nextPage.$state,
isLoading: this.$isNextLoading,
},
mapParams: (_: void, { page, isLoading }) => ({ page, isLoading }),
effect: this.getNextPageFx,
})
public readonly getNextSync = () => {
this.getNext().catch(noop)
}
public readonly refresh = createEffect(() => {
return this.get(this.defaultProps!)
})
public readonly $isRefreshing = this.refresh.pending
public readonly refreshSync = () => {
this.refresh().catch(noop)
}
public readonly setItems = createEvent<T[]>()
public readonly addItems = createEvent<T[]>()
public readonly $items = restore<T[]>(this.setItems, []).on(
this.addItems,
(state, payload) => [...state, ...payload],
)
public readonly reset = () => {
this.nextPage.reset()
this.setItems([])
}
}
export const createPaginatedListModel = <T, R, P>(
props: PaginatedListModelProps<T, R, P>,
) => {
return new PaginatedListModel(props)
}
And usage for different lists:
News:
export const newsListModel = createPaginatedListModel({
request: api.news.get,
itemExtractor: (response) => response.news,
nextPageFilter: ({ page, total, response }) => {
if (response.news_count < total) return page + 1;
return null;
},
});
const NewsList = ({}: NewsListProps) => {
useEffect(() => {
newsListModel.init();
});
return (
<ScreenList
categories={categories}
model={newsListModel}
Item={NewsCard}
detailsLink={Links.NEWS_DETAILS}
keyExtractor={idExtractor}
/>
);
};
Events:
export const eventsListModel = createPaginatedListModel({
request: api.events.get,
itemExtractor: (response) => response.events,
nextPageFilter: ({ page, total, response }) => {
if (response.events_count < total) return page + 1;
return null;
},
});
const EventsList = ({}: EventsListProps) => {
useEffect(() => {
eventsListModel.init();
});
return (
<ScreenList
categories={categories}
model={eventsListModel}
Item={EventCard}
detailsLink={Links.EVENT_DETAILS}
keyExtractor={idExtractor}
/>
);
};
We can even go further and create components that will get models as a props
Implementing of FormModel (+Component)
export type TypedFormFieldComponentProps<
T extends Record<string, any>,
K extends keyof T,
ST
> = {
name: T[K] extends ST ? K : never
formModel: FormModel<T>
}
export type FieldProps<T extends Record<string, any>, N extends keyof T, V> = {
label?: string
style?: InputStyles
postfix?: string
validateOnBlur?: boolean
} & TypedFormFieldComponentProps<T, N, V>
const Field = <T extends Record<string, any>, N extends keyof T>({
name,
formModel,
style,
validateOnBlur,
onBlur,
...props
}: FieldProps<T, N, string> & Omit<InputProps, 'style'>) => {
const [value, setValue] = useFormField(formModel, name)
const validation = useFieldValidation(formModel, name)
return (
<Input
onChangeText={(text) => {
setValue(text)
formModel.validation.resetField(name)
}}
value={value}
styles={style}
onBlur={(e) => {
if (validateOnBlur) {
if (!value) return formModel.validation.resetField(name)
formModel.validation.castField(name)
}
onBlur?.(e)
}}
isValid={validation?.isValid}
{...props}
/>
)
}
export const useFormField = <T extends Record<string, any>, K extends keyof T>(
form: FormModel<T>,
key: K
) => {
const fieldValue = useStoreMap({
store: form.$store,
keys: [key, form],
fn: (fields) => fields[key],
})
const updateField = useCallback(
(value: T[K]) => form.setField({ value, key }),
[form, key]
)
return [fieldValue, updateField] as [T[K], (value: T[K]) => void]
}
export class FormModel<T extends Record<string, any>, R = any> {
private readonly fieldsSettings: Partial<Record<keyof T, FieldSettings>> = {}
private readonly schema
public readonly reset = createEvent<Event<any> | void>()
public readonly setFieldEvent = createEvent<FieldPair<T, keyof T>>()
public setField<K extends keyof T>(props: FieldPair<T, K>) {
this.setFieldEvent(props)
}
public readonly setSomeFields = createEvent<Partial<T>>()
public readonly set = createEvent<T>()
public readonly $store
public readonly fields: { [K in keyof T]: K }
public readonly keysList
public readonly validation
public readonly submit
private submitRequest: Effect<T, any> | null = null
private isValidateOnSubmit = false
constructor(schema: T | ObjectSchema<T>, settings?: SubmitSettings<T, R>) {
this.schema = schema
const isYupSchema = schema.__isYupSchema__
const initialState: T = schema.__isYupSchema__
? schema.getDefault()
: schema
this.$store = createStore<T>(initialState)
.on(this.setFieldEvent, (store, { key, value }) => {
const fieldMapper = this.fieldsSettings[key]?.map
return {
...store,
[key]: fieldMapper ? fieldMapper(value) : value,
}
})
.on(this.setSomeFields, (store, fields) => ({ ...store, ...fields }))
.on(this.set, (_, payload) => payload)
.on(this.reset, (_, payload) => {
if (!payload) return initialState
})
this.submit = attach({
source: this.$store,
mapParams: (_: void, store) => store,
effect: createEffect<T, any extends R ? undefined : R>(async (props) => {
if (this.isValidateOnSubmit) {
const result = await this.validation.cast()
if (!result.isValid) throw Error('Validation error')
}
if (!this.submitRequest) return
return this.submitRequest(props)
}),
})
this.validation = createValidator(schema, this.$store)
this.validation.reset(this.reset)
if (settings) this.setUpSettings(settings)
this.$store.watch(() => {
this.validation.reset()
})
this.reset.watch((event) => {
if (event) event.watch(() => this.reset())
})
this.keysList = Object.keys(initialState) as (keyof T)[]
if (isYupSchema) this.keysList.reverse()
this.fields = mapObject(initialState, (_, key) => key) as {
[K in keyof T]: K
}
}
private setUpSettings(settings: SubmitSettings<T, R>) {
if (settings.validate) {
this.isValidateOnSubmit = true
}
if (settings.request) {
this.submitRequest = settings.request
}
}
public mapKeys<U>(fn: (value: keyof T) => U): U[] {
return this.keysList.map(fn)
}
public setSubmitSettings<Return>(settings: SubmitSettings<T, Return>) {
this.setUpSettings(settings as SubmitSettings<T, any>)
return this as any as FormModel<T, Return>
}
public getField<K extends keyof T>(field: K) {
return this.$store.getState()[field]
}
private getFieldSettings(name: keyof T) {
if (!this.fieldsSettings[name]) this.fieldsSettings[name] = {}
return this.fieldsSettings[name]!
}
public addFieldMap<K extends keyof T>(name: K, fn: (value: T[K]) => T[K]) {
const settings = this.getFieldSettings(name)
settings.map = fn
return this
}
public setFieldsSettings(
settings: Partial<{ [K in keyof T]: FieldSettings<T[K]> }>
) {
Object.assign(this.fieldsSettings, settings)
return this
}
}
export const createFormModel = <T extends Record<string, any>>(
initialFormState: T | ObjectSchema<T>
) => {
return new FormModel<T>(initialFormState)
}
Usage of FormModel
const imageFileShape = yup.object().shape({
uri: yup.string().required(),
name: yup.string().required(),
size: yup.number().required(),
})
const schema: ObjectSchema<ImageDescriptionFormFields> = yup.object().shape({
age: stringSchema(),
title: stringSchema(),
categoryId: yup.number().default(0),
image: imageFileShape.default(null),
})
export const createPostFormModel = createFormModel(schema).setSubmitSettings({
validate: true,
request: createEffect(async (data: ImageDescriptionFormFields) => {
if (data.categoryId === null) return
return api.arts.create({
image: data.image,
title: data.title,
categoryId: data.categoryId,
})
}),
})
createPostFormModel.submit.done.watch(({ result }) => {
if (!result) return
})
...
return (
<ScrollView
bounces={false}
style={styles.common.container}
contentContainerStyle={styles.common.scrollContent}
>
<ImagePreviewFormField
name={createPostFormModel.fields.image}
formModel={createPostFormModel}
style={styles.common.image as ImageStyle}
/>
<H3 style={styles.common.header} label={text.completeDescription} />
<Field
label={text.title}
name={createPostFormModel.fields.title}
formModel={createPostFormModel}
styles={fieldStyles}
/>
<CategoriesSelect model={selectedCategoryModel} />
<Field
disabled
label={text.age}
name={createPostFormModel.fields.age}
formModel={createPostFormModel}
postfix={` ${text.yearsOldAbbreviated}`}
styles={styles.field}
/>
{!isChildDocumentDetermined && (
<ChildDocumentUploadingBlock
style={styles.common.cameraBlock}
containerStyle={styles.common.cameraBlockContainer}
/>
)}
<CreatePostFromSubmitButton style={styles.common.button} />
</ScrollView>
)
View (Components)
Keep inside feature only components that use model. If you have component that contain only ui, even if it uses
only in this specific feature, transmit it to ui folder anyway. But remember that ui components should not
contain any theme-specific styles, or i18n labels.
Files
There some types of files that we keep in feature folder: like model, helpers, Components, requests, etc. But you also may create file with specific names you need.
Examples (simple)
Some models for one feature:
Custom file naming:
No model:
Inherited features. There some cases when feature may contain sub-feature, and it is ok. But try to keep structure as flat as possible. You can use rule, "only one additional inheritance inside specific feature". Examples of healthy inheritance:
Grouping of similar features / sub-features
Or we can divide complex models / helpers to their own folders (but since this is feature based approach it is better to split models by sub features, as in examples above rather than create big can for all inner models)
Transmitting complex parts to separated folders inside feature
Even though as a general rule we aim to store UI components in a dedicated UI folder, there are times when exceptions need to be made. A clear example of this is the chat feature which is fairly complex and contains several sub-features. So fo saving modularity and isolation it makes sense to keep some ui components for chat inside this feature folder