React component file structure proposal
12 Aug 2022
This is an attempt at a proposal for organizing a React component file structure based on the needs and current patterns at JupiterOne.
I'm sharing this first on my blog to get some external feedback, but I also think its a good discussion point in general. For context, our tech stack uses the following: TypeScript, React, Jest, and Storybook. The idea is that this code will be generated as part of a code generator (such as yeoman).
In the remainder of this document, Foo
will be used to represent the user-supplied name.
The pattern has the following file structure and naming:
{Foo}/
├──index.tsx
├──use{Foo}Logic.ts
├──use{Foo}Logic.test.tsx
├──{Foo}Component.tsx
└──{Foo}Component.stories.tsx
Reasoning
- index.tsx helps make importing simple, eg.
import { Foo } from './Foo'
- 'use{Foo}Logic' encourages colocating business logic with its unit test (Jest)
- '{Foo}Component' encourages colocating UI-related code with its corresponding story (Storybook)
- Generating a component as part of developer onboarding experience would be a great primer for how these should work and establish best practices
File contents
To better illustrate the need for such a pattern, I've filled in the contents of the files below.
index.tsx
All this file does is export the hooked component, and creates a type for props.
import { FooComponent } from './FooComponent';
import { useFooLogic } from './useFooLogic';
// NOTE: having this into index.ts creates a circular dependency.
// I'm unsure whether this is okay given that this is just a Type, which is not included in compiled code...
// but makes for a better match for condensed file structure (see below). Alternatively this could be
// colocated in use{Foo}Logic file.
export type FooProps = ReturnType<typeof useFooLogic>;
export const Foo = withHookHoc(FooComponent, useFooLogic);
use{Foo}Logic.ts
This file should only contain business logic. Its return value will be passed as props to the component. The extra "Logic" suffix can serve as a constant reminder, but may not actually be necessary as React hooks are intended to be thought of as containing component logic. TBD perhaps.
import React from 'react';
export const useFooLogic = () => {
return {};
};
use{Foo}Logic.test.ts
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useFooLogic } from './useFooLogic';
import type { FooProps } from '.';
describe(useFooLogic, () => {
const mockProps: FooProps = {};
const render = (testProps: Partial<FooProps> = {}) => {
return renderHook(useFooLogic, {
initialProps: {
...mockProps,
...testProps,
},
});
};
it('returns valid default props', async () => {
const { result } = render();
expect(result.current).toBeDefined(); // TODO: have a better default test...
});
});
Additional ideas:
- optionally allow generating Apollo MockedProvider code & wrapper
- optionally allow generating react-router MemoryRouter code & wrapper
{Foo}Component.tsx
The extra "Component" suffix helps remind the developer that only the component code should be here, though it does look a bit awkward at first glance. Alternative ways to think about this is that this is "headless component" that contains next-to-no logic (though not always avoidable).
import React from 'react';
import { withHookHoc } from '@jupiterone/web-apps-core';
import { makeUseStyles } from '@jupiterone/web-juno';
import type { FooProps } from '.';
const useStyles = makeUseStyles((theme) => {
return {};
});
export const FooComponent = (props: FooProps) => {
const styles = useStyles();
return <></>;
};
{Foo}Component.stories.tsx
import React from 'react';
import { storybookTemplate } from '@jupiterone/web-apps-core';
import { FooComponent } from './FooComponent';
import type { FooProps } from '.';
// TODO: update storybookTemplate to accept typings
const { template, meta } = storybookTemplate<FooProps>({
title: 'Foo',
component: FooComponent,
});
export default meta;
export const Default = template({});
Extra credit: condensed file structure
If, say, you have a very simple component that doesn't need 5 files... just reduce it down to the one index file but retaining the file names for tests.
{Foo}/
├──index.tsx
├──use{Foo}Logic.test.tsx
└──{Foo}Component.stories.tsx
index.tsx
import React from 'react';
import React from 'react';
import { withHookHoc } from '@jupiterone/web-apps-core';
import { makeUseStyles } from '@jupiterone/web-juno';
/* --- Logic --- */
export const useFooLogic = () => {
return {};
};
export type FooProps = ReturnType<typeof useFooLogic>;
/* --- Component --- */
const useStyles = makeUseStyles((theme) => {
return {};
});
export const FooComponent = (props: FooProps) => {
const styles = useStyles();
return <></>;
};
export const Foo = withHookHoc(FooComponent, useFooLogic);
use{Foo}Logic.test.ts
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useFooLogic } from '.';
import type { FooProps } from '.';
describe(useFooLogic, () => {
const mockProps: FooProps = {};
const render = (testProps: Partial<FooProps> = {}) => {
return renderHook(useFooLogic, {
initialProps: {
...mockProps,
...testProps,
},
});
};
it('returns valid default props', async () => {
const { result } = render();
expect(result.current).toBeDefined();
});
});
{Foo}Component.stories.tsx
import React from 'react';
import { storybookTemplate } from '@jupiterone/web-apps-core';
import { FooComponent } from '.';
import type { FooProps } from '.';
// TODO: update storybookTemplate to accept typings
const { template, meta } = storybookTemplate<FooProps>({
title: 'Foo',
component: FooComponent,
});
export default meta;
export const Default = template({});
What do you think? Would you use a codebase structured like this?