At Yeti, we create a large number of apps from the ground up due to the number of startups we work with. Every new app gives us the opportunity to improve our tech stack and one if its important pieces - folder structure.
Imagine a scenario in which we have simple requirements, which warrants a simple app and a simple folder structure. Makes sense. However, as we start scaling our app, this simple folder structure becomes an issue. Let's say we place all of our components in a folder called components. Every component that lives under the components directory seems to have equal weighting, leaving sub components of other components and components that are really screens on the same level. Any developer trying to understand how everything connects will have a difficult time seeing the shape of the app.
Our redux code becomes a mess too. Our files containing our actions become monoliths. Our initial slices of redux state no longer represent what's inside of them. We're now unnecessarily digging for logic while holding too many files in our working memory. One tap on the shoulder from an unassuming colleague and all of the connections we've been forming between pieces of code and logic in our app evaporate.
Dealing with these problems has led us to create a set of folders that hold specialized purposes in our apps, allowing easier maintenance and less reliance on tribal knowledge.
To kick off the most important folder, there are two things to keep in mind - verticality and horizontality. Folders that have many files or folders within them one level deep are very horizontal, while folders that have many nested subfolders are very vertical. It is important to keep a balance between these two attributes since going too far in either direction will cause confusion and increase the complexity of the mental model of the application.
If we keep everything in one folder called components, for example, the shape of the application and how the code relates to each other will not be apparent. However, if we create too many nested folders and start sharing information between subparts of the application at too many different levels, we over-abstract and actually make the app more complex than necessary.
Each module represents a certain section or theme of the app. Common modules that can be frequently prevelant across apps are 'authentication', 'profile', and 'onboarding'. Used wisely, thematic modules like these can simplify the understanding the application and provide sufficient sandboxing from other, unrelated parts of the app.
Within each module, we only create three more folders: dux , a spin off of ducks modular redux where we export our reducer and actions, screens , which correspond to the different screens the router will be displaying, and a third for action creators or other side effects. For redux-thunk, we call it thunks , for redux saga , sagas , and for redux observable , epics .
Tip: Reducing Boilerplate
In order to reduce boilerplate and avoid the necessity to use numerous spread operators in our reducers, we use immer reducer. It also supports TypeScript and automatically creates actions.
At its simplest, this folder houses two files: rootReducer and store . To those familiar with redux, these should be pretty self explanatory. Our rootReducer combines all of our main reducers from our modules and our store creates the central brain for redux.
Tip: Side Effect Setup
If we're working with any side effect libraries like redux observable or redux saga , we'll need somewhere to put their setup boilerplate. This is a good place.
Services follow the same paradigm as modules in that they are modular. Each service is meant to provide specific functionality or business logic. Examples of services that we frequently use are an Http service and Navigation service, which work well in both our react native and react web projects. Our Http service is a base class, which is helpful for creating additional services like Backend that extend Http and connect with a backend like Django.
Fun fact: Before we were a React shop, we primarily developed in Angular, which is where the inspiration for how we utilize services comes from. The difference being we just import our services directly as ES6 modules instead of using dependency injection.
Things that go in here have usually elevated from the ranks of single use and are now ready to be sent across the entire application. Some good examples of what to put in here are styles, typically with fonts and colors acting as files within, and components , with things like button and text . A good rule of thumb is that components should only go in here if we know that the design uses this element in multiple places or that they are not sub components of a component or screen . Another worthy mention is a utils folder or file, for those one off functions that have unique business logic.
We use TypeScript for all of our projects, so this folder acts as the single source of truth for our types. Even if you are using PropTypes, don't forget that those can be abstracted as well!
We've experimented with keeping types scattered throughout the other folders before, but have found that developers not acquainted with the code base struggle to find where types live. Types then start to become duplicated in multiple places. Having a code editor with Intellisense helps with both of these problems, but only when you're familiar with the names.
We actually create folders in the types folder that are very similar to Five Folders. These are services , state , and shared . state is just an abstraction over the different modules. This allows us to still keep the same mental model as the src folder, reducing cognitive load. This also has the increased benefit of knowing that importing from types/.. will always get you where you need to go.
All of this idealistic philosophy is great, but does it actually scale?
In short, yes. We've created multiple projects that have scaled to over 100 components using this folder structure.
Is it modular?
Yup. One of the most common occurrences during a refactor is to repurpose a component to be shared. Since everything is in a folder, tests included, we simply just need to drag and drop it into the shared/components folder. Technically, it's actually not that simple, since we need to change the imports from where we're calling that component, but if we're using TypeScript the code won't even compile before those are fixed, so we'll be safe.
Is it extendable?
Yes. When adding functionality, the biggest pieces are usually new sections / flows within the app and integrating with new third party code. Our services folder can easily accommodate new services, as well as our modules, since we can just add a new module that also encompasses a new slice of state.
Want an example of this philosophy in action? Check out Glamper, a starter kit that we use at Yeti to scaffold new projects.
The Yeti team recently developed a new internal tool called "Yurt" - a command line application designed to streamline the process of setting up new code bases by incorporating our preferred preferences and patterns. Take a look at our article and video to learn how you can do the same!
Streamline workflows and boost productivity with AI powered software development! In this article and video seasoned software engineer James Feore discusses how how you can get the improve your development process with ChatGPT and Co-Pilot. Get ready to explore the transformative capabilities of Chat GPT for real-world applications.
Part of the Yeti Lunch and Learn series - our amazing developer, Resdan, gives a presentation on creating a reusable component library. Enjoy the video!