Skip to content

Instantly share code, notes, and snippets.

@KeKs0r
Last active July 8, 2022 14:56
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save KeKs0r/402f340afc3e111e8c91cab8b9274c66 to your computer and use it in GitHub Desktop.
Save KeKs0r/402f340afc3e111e8c91cab8b9274c66 to your computer and use it in GitHub Desktop.
Marcs React Structure 2019 Examples

How I currently write React Apps (2019 Hooks Edition)

Foreword

Most of my products are very MVP-ish, so I dont focus too much on super clean code and high quality, but there are a few patterns that I reuse quite a bit and find useful. But this is not an exhaustive list and the examples might not be as clean as one would think.

Technology Stack

Usually I am not using any sophisticated state Library anymore, the react hooks useState and useReducer in combination with context is absolutely sufficient, at least for UI-State. Everything else is in data persistence, which is in my case either Firebase or Graphql (= Apollo).

Folder Structure (Create React App-Basis)

root

  • src
    • components (common components across different areas)
    • hooks (common utility hooks)
    • Layout (Contains different layouts and its components such as menus, etc. ) // And then folders for different Areas of the applicaiton e.g.
    • Search
    • User
    • Worklist

components

Components contains all commonly shared visual components. Most of them are probably not very logic heavy, since shared logic makes them less reusable. I also share some of them between my projects, since I build most projects on Material-UI, I can also reuse some visual components Examples:

  • AutoComplete
  • DateDropdown
  • FormLayout
  • Accordion

I usually create a folder per component, in order to also add a story or potentially test for them. (I test UI very rarely)

Another thing I do is, if the component is a Form Element, I add a "Field" version of that component, in order to use the Form Element such as AutoComplete or DateDropdown with final-form.

Example of a Field Component

components/DateDropdown/DateDropdownField.js

import React from "react";
import DateDropdown from "./date-dropdown";

export const DateDropdownField = ({
  input: { name, onChange, value, ...restInput },
  meta,
  ...rest
}) => {
  const showError =
    ((meta.submitError && !meta.dirtySinceLastSubmit) || meta.error) &&
    meta.touched;
  const errorText = meta.error || meta.submitError;

  return (
    <DateDropdown
      {...rest}
      helperText={showError && errorText.length > 0 ? errorText : undefined}
      error={showError}
      name={name}
      onChange={onChange}
      value={value}
    />
  );
};

export default DateDropdownField;

And then I am able to use this with final form:

<Field
    component={DateDropdownField}
    name="when"
    autoFocus
    fullWidth
    disabled={canEditSubject}
    onClick={editTime}
    onSelect={selectedItem => {
    console.log("onSelect", selectedItem);
    selectedItem && focusAction();
    }}
/>

hooks

This contains reusable utility hooks, that usually dont have anything to do with bbusiness logic. Examples:

  • useCountdown
  • useDebounce
  • usePersistedReducer (a version of useReducer, that caches results in localstorage)\

Example useCountdown

import { useEffect, useState } from "react";

function useCountdown(date, options = {}) {
  const { intervalTime = 1000, now = () => Date.now(), onFinish } = options;
  const [timeLeft, setTimeLeft] = useState(
    () => new Date(date()) - new Date(now())
  );

  useEffect(() => {
    const interval = setInterval(() => {
      setTimeLeft(current => {
        if (current <= 0) {
          clearInterval(interval);
          onFinish && onFinish();
          return 0;
        }

        return current - intervalTime;
      });
    }, intervalTime);

    return () => clearInterval(interval);
  }, [intervalTime, onFinish, timeLeft]);

  return timeLeft;
}

export default useCountdown;

Application Area Folders

These are the folders that contain the actual application parts in my case these are

  • Person
  • Search
  • Auth
  • Worklist
  • Activities I am putting everything related to one "Area" into one folder. That means I dont split my folder structure by type (e.g. component, hook, container, state, query, mutation ....), but I bundle all of them by domain. Exception are only Utility and Common components, which are stored on the root folder and we discussed before.

Business Domain / Application Area Folders

Example

  • Person
    • stories (Storybbook stories)
    • tests (in case I am writing tests)
    • modules (This is where business logic goes, think actions/reducers/store in redux world, but combined)
    • ...All Components and potentially subfolders for better structure

Modules

I usually distinguish between 3 types of things in my Modules Folder:

  • Hook
    • Query
    • Mutation
  • Context
  • Container

Hook

I put my business logic hooks here. Those could be just some simple state things, or graphql queries / mutations. Examples: src for all

  • Simple State Hook: useActivityItemState.js
  • Graphql Query Hook: usePersonQuery.js
  • Graphql Mutation Hook: useScheduleActivity.js

Context

Sometimes I have a very deep tree and I dont want to pass on all my objects through several layers. In this case I just put the data into a Context and access it way down. Example: PersonContext.js

Container

Container are a combination of a Logic Hook and a Context. Sometimes you have some state that needs to be passed down a big tree and be accessed in multiple places, but it also can be modified from multiple places. In these cases I use Containers, that combine bboth concepts. I did make them manually, but for convention reason I am using a library now: unstated-next (which has 40 lines of code). It is just a pattern and does not require a library though. Example: useNewActivityContainer.js

Components within the Application Area Folder

The only thing missing now, are the components that bring everything together. They are just pure react components and are tried to be structured by visual as well as logical concern. Which is admitetedly very hard sometimes especially when the product is changing quite rapidly.

One thing that I do with them, especially since I am often building components up front in storybook is seperating the visual part of a component from its behavioural part. That allows me to only develop the visual part in storybbook. Or even when I am also developing the behavioural part, to at least seperate the behaviour from things as data fetching, which is not really a thing I care about in storybook.

Example: PersonPage

import React, { createContext, useContext } from "react";
const PersonContext = createContext();
export function Provider({ person, children }) {
return (
<PersonContext.Provider value={person}>{children}</PersonContext.Provider>
);
}
export default function usePersonContext() {
return useContext(PersonContext);
}
import React, { useEffect } from "react";
import { VerticalList } from "react-key-navigation";
import CompanyHeader from "./CompanyHeader";
import PersonHeader from "./PersonHeader";
import PersonActivities from "../Activities/PersonActivities";
import usePersonHeader from "./modules/usePersonHeaderQuery";
import HistoryContainer from "../Search/modules/history-container";
import { Provider as PersonContextProvider } from "./PersonContext";
import { NewActivityContainer } from "./modules/useNewActivityContainer";
import ScheduleActivity from "./PersonScheduleActivity";
import { ActivityListContext } from "./modules/activity-list-context";
export function PersonPage({ personId, person }) {
return (
<VerticalList focusId="PersonPage">
<CompanyHeader companyName={person.Company} focusId="CompanyHeader" />
<PersonHeader
forceFocus
name={person.Name}
title={person.Title}
interests={person.Interests}
status={person.Status}
image={person.PhotoUrl}
focusId="PersonHeader"
/>
<ActivityListContext.Provider>
<ScheduleActivity personId={personId} />
<PersonActivities personId={personId} />
</ActivityListContext.Provider>
</VerticalList>
);
}
export default function PersonPageContainer({ personId }) {
const { data, loading, error } = usePersonHeader(personId);
const { push } = HistoryContainer.useContainer();
useEffect(() => {
if (!data.getLeadById) {
return;
}
const person = data.getLeadById;
push({
Id: person.Id,
Name: person.Name,
PhotoUrl: person.PhotoUrl,
Company: person.Company
});
}, [data.getLeadById, push]);
if (loading) {
return <span>Loading</span>;
}
if (error) {
return <span>{error.message}</span>;
}
return (
<PersonContextProvider person={data.getLeadById}>
<NewActivityContainer.Provider>
<PersonPage personId={personId} person={data.getLeadById} />
</NewActivityContainer.Provider>
</PersonContextProvider>
);
}
import { useReducer, useCallback } from "react";
import useExpandActivityItem from "./ActivityListContext";
const CHANGE_CHANNEL = "activity:change_channel";
const START_EDIT_SUBJECT = "activity:start_edit_subject";
const STOP_EDIT_SUBJECT = "activity:stop_edit_subject";
const START_EDIT_DATE = "activity:start_edit_date";
const STOP_EDIT_DATE = "activity:stop_edit_date";
const START_EDIT_DESCRIPTION = "activity:start_edit_description";
const STOP_EDIT_DESCRIPTION = "activity:stop_edit_description";
const FIELD_SUBJECT = "field:subject";
const FIELD_DATE = "field:date";
const FIELD_DESCRIPTION = "field:description";
function activityItemReducer(state, action) {
console.log(action);
switch (action.type) {
case CHANGE_CHANNEL: {
return {
...state,
channel: action.channel
};
}
case START_EDIT_SUBJECT: {
return {
...state,
isEditingField: FIELD_SUBJECT
};
}
case START_EDIT_DATE: {
return {
...state,
isEditingField: FIELD_DATE
};
}
case START_EDIT_DESCRIPTION: {
return {
...state,
isEditingField: FIELD_DESCRIPTION
};
}
case STOP_EDIT_DATE:
case STOP_EDIT_DESCRIPTION:
case STOP_EDIT_SUBJECT: {
return {
...state,
isEditingField: false
};
}
default:
return state;
}
}
export default function useActivityItem(activity) {
const { isExpanded, expand, collapse } = useExpandActivityItem(activity.Id);
const [state, dispatch] = useReducer(activityItemReducer, {
channel: activity.Type,
isEditingField: false
});
const changeChannel = useCallback(
channel => dispatch({ type: CHANGE_CHANNEL, channel }),
[]
);
const startEditSubject = useCallback(() => {
dispatch({ type: START_EDIT_SUBJECT });
}, []);
const stopEditSubject = useCallback(
() => dispatch({ type: STOP_EDIT_SUBJECT }),
[]
);
const startEditDate = useCallback(
() => dispatch({ type: START_EDIT_DATE }),
[]
);
const stopEditDate = useCallback(
() => dispatch({ type: STOP_EDIT_DATE }),
[]
);
const startEditDescription = useCallback(
() => dispatch({ type: START_EDIT_DESCRIPTION }),
[]
);
const stopEditDescription = useCallback(
() => dispatch({ type: STOP_EDIT_DESCRIPTION }),
[]
);
const fullState = {
...state,
isEditingDate: state.isEditingField === FIELD_DATE,
isEditingSubject: state.isEditingField === FIELD_SUBJECT,
isEditingDescription: state.isEditingField === FIELD_DESCRIPTION,
isExpanded
};
return {
state: fullState,
expand,
collapse,
changeChannel,
startEditSubject,
stopEditSubject,
startEditDate,
stopEditDate,
startEditDescription,
stopEditDescription
};
}
import { createContainer } from "unstated-next";
import { useState, useCallback } from "react";
function useNewActivityContainerState() {
return useState(false);
}
export const NewActivityContainer = createContainer(
useNewActivityContainerState
);
export default function useNewActivity() {
const [
isSchedulingActivity,
setSchedulingActivity
] = NewActivityContainer.useContainer();
const showScheduleActivity = useCallback(() => setSchedulingActivity(true), [
setSchedulingActivity
]);
const hideScheduleActivity = useCallback(
() => setSchedulingActivity(false),
[setSchedulingActivity]
);
return {
isSchedulingActivity,
showScheduleActivity,
hideScheduleActivity
};
}
import gql from "graphql-tag";
import { useQuery } from "react-apollo-hooks";
import PersonHeader from "../PersonHeader";
import CompanyHeader from "../CompanyHeader";
export const GET_LEAD_MAIN = gql`
${PersonHeader.fragment}
${CompanyHeader.fragment}
query getPersonById($id: ID!) {
getLeadById(id: $id) {
...PersonHeader
...CompanyHeader
# # Additional Fields to not do multiple queries
# Title
# Phone
# MobilePhone
# OwnerId
# Website
# LeadSource
# Description
# Interests
# Rating
# FirstName
# LastName
Email
# SecondEmail
# EmailAddressHistory
}
}
`;
export default function usePersonQuery(personId) {
return useQuery(GET_LEAD_MAIN, {
variables: { id: personId },
suspend: true
});
}
import gql from "graphql-tag";
import { useMutation } from "react-apollo-hooks";
import { useCallback } from "react";
import {
removeLeadFromUnscheduled,
isDueToday,
addActivityToWorklist
} from "../../Worklist/worklist-store";
import { addActivityToLead } from "./usePersonActivitiesQuery";
import { get } from "lodash";
export function isEvent(when) {
return !!when.tokens.find(
t =>
t.type === "hour" ||
t.type === "minute" ||
(t.type === "unit" && (t.value === "minutes" || t.value === "hours"))
);
}
export const SCHEDULE_TASK_MUTATION = gql`
mutation createTask($task: TaskInput!) {
createTask(input: $task) {
Id
LeadId
Type
Subject
ActivityDate
ActivitySubtype
}
}
`;
const SCHEDULE_EVENT_MUTATION = gql`
mutation createEvent($event: EventInput!) {
createEvent(input: $event) {
Id
LeadId
Type
Subject
StartDateTime
ActivitySubtype
}
}
`;
export function useCreateTask(contactId) {
const addTask = useMutation(SCHEDULE_TASK_MUTATION, {
update: (cache, mutationResult) => {
const task = mutationResult.data.createTask;
const taskDate = get(task, "ActivityDate");
if (!taskDate) {
console.warn("Somehow the taskdate did not come back");
}
if (isDueToday(taskDate)) {
addActivityToWorklist(cache, task);
}
removeLeadFromUnscheduled(cache, contactId);
addActivityToLead(cache, contactId, task);
}
});
return addTask;
}
export default function useScheduleActivity(contactId) {
const addTask = useCreateTask(contactId);
const addEvent = useMutation(SCHEDULE_EVENT_MUTATION, {
update: (cache, mutationResult) => {
const event = mutationResult.data.createEvent;
const date = get(event, "StartDateTime");
if (!date) {
console.warn("Somehow the Event StartDateTime did not come back");
}
if (isDueToday(date)) {
addActivityToWorklist(cache, event);
}
removeLeadFromUnscheduled(cache, contactId);
addActivityToLead(cache, contactId, event);
}
});
const action = useCallback(
values => {
const { when, channel, subject } = values;
const base = {
LeadId: contactId,
Subject: subject,
Type: channel && channel.value
};
if (isEvent(when)) {
const event = {
...base,
StartDateTime: when.date,
DurationInMinutes: 30
};
return addEvent({
variables: { event }
});
} else {
const task = {
...base,
ActivityDate: when.date
};
return addTask({
variables: { task }
});
}
},
[addEvent, addTask, contactId]
);
return action;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment