├── .editorconfig ├── .eslintrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── .travis.yml ├── .yarnclean ├── README.md ├── index.html ├── package.json ├── renovate.json ├── setup-test.ts ├── src ├── App.css.ts ├── App.tsx ├── __tests__ │ ├── About.test.tsx │ ├── Counter.test.tsx │ ├── Missing.test.tsx │ ├── Navigation.test.tsx │ └── helpers.tsx ├── components │ ├── Counter.css.ts │ ├── Counter.tsx │ ├── Navigation.css.ts │ ├── Navigation.tsx │ ├── Spinner │ │ ├── index.tsx │ │ └── styles.css.ts │ └── __tests__ │ │ └── Spinner.test.tsx ├── errors │ └── index.ts ├── hoc │ ├── __tests__ │ │ ├── withDelay.test.tsx │ │ └── withErrorBoundary.test.tsx │ ├── withDelay.tsx │ ├── withErrorBoundary.css.ts │ └── withErrorBoundary.tsx ├── hooks │ └── useUpdateTitle.ts ├── index.css.ts ├── index.tsx ├── pages │ ├── About.tsx │ ├── Counter.tsx │ └── Missing.tsx ├── state │ ├── __tests__ │ │ └── createContextualReducer.test.tsx │ ├── createContextualReducer.tsx │ └── useCounter.tsx └── theme.css.ts ├── tsconfig.json ├── types └── markdown.d.ts ├── vite.config.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = false 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | extends: 2 | - react-app 3 | - prettier 4 | - plugin:@typescript-eslint/recommended 5 | plugins: 6 | - prettier 7 | parser: '@typescript-eslint/parser' 8 | rules: 9 | prettier/prettier: error 10 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Setup Node.js environment 19 | uses: actions/setup-node@v2.4.1 20 | with: 21 | node-version: 14.x 22 | cache: yarn 23 | 24 | # Runs a set of commands using the runners shell 25 | - name: Build 26 | run: | 27 | yarn 28 | yarn ci 29 | 30 | - name: Coveralls GitHub Action 31 | uses: coverallsapp/github-action@1.1.3 32 | with: 33 | github-token: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /dist 4 | /.cache 5 | /coverage 6 | /reports 7 | /.vscode 8 | /yarn-error.log 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | printWidth: 100 2 | singleQuote: true 3 | trailingComma: "all" 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | script: npm run ci 5 | -------------------------------------------------------------------------------- /.yarnclean: -------------------------------------------------------------------------------- 1 | @types/react-native 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Typescript/React Example Project [![Build Status](https://github.com/drewschrauf/typescript-react-redux/actions/workflows/main.yml/badge.svg?branch=master)](https://github.com/drewschrauf/typescript-react-redux/actions/workflows/main.yml?query=branch%3Amaster) [![Coverage Status](https://coveralls.io/repos/github/drewschrauf/typescript-react-redux/badge.svg?branch=master)](https://coveralls.io/github/drewschrauf/typescript-react-redux?branch=master) 2 | 3 | _**Now with 100% less Redux!**_ 4 | 5 | ## What is this? 6 | 7 | This is a basic example project using the following technologies to build a web app: 8 | 9 | - Typescript 10 | - React 11 | - React Router 12 | - Vanilla Extract 13 | - Vite 14 | 15 | And for testing: 16 | 17 | - Jest 18 | - react-testing-library 19 | 20 | ## What happened to Redux? 21 | 22 | It turns out that with the power of hooks, you really don't need Redux anymore. If you can't live without it, you'll find that the types used for the reducer and actions here work just as well for Redux. 23 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Typescript React Redux 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-react-redux", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": {}, 6 | "author": "Drew Schrauf", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "vite", 10 | "build": "vite build", 11 | "lint": "yarn lint:eslint && yarn lint:types", 12 | "lint:eslint": "eslint --ext ts,tsx src ", 13 | "lint:types": "tsc", 14 | "test": "jest", 15 | "test:coverage": "jest --verbose --coverage --coverageReporter=lcov", 16 | "ci": "yarn lint && yarn test:coverage && yarn build" 17 | }, 18 | "dependencies": { 19 | "core-js": "3.18.2", 20 | "normalize.css": "8.0.1", 21 | "react": "17.0.2", 22 | "react-dom": "17.0.2", 23 | "react-is": "17.0.2", 24 | "react-router-dom": "5.3.0" 25 | }, 26 | "devDependencies": { 27 | "@testing-library/jest-dom": "5.14.1", 28 | "@testing-library/react": "12.1.2", 29 | "@types/jest": "27.0.2", 30 | "@types/markdown-it": "^12.2.3", 31 | "@types/react": "17.0.27", 32 | "@types/react-dom": "17.0.9", 33 | "@types/react-router-dom": "5.3.1", 34 | "@typescript-eslint/eslint-plugin": "4.33.0", 35 | "@typescript-eslint/parser": "4.33.0", 36 | "@vanilla-extract/babel-plugin": "^1.1.1", 37 | "@vanilla-extract/css": "^1.6.1", 38 | "@vanilla-extract/vite-plugin": "^2.1.2", 39 | "@vitejs/plugin-react-refresh": "^1.3.6", 40 | "eslint": "7.32.0", 41 | "eslint-config-prettier": "8.3.0", 42 | "eslint-config-react-app": "6.0.0", 43 | "eslint-plugin-flowtype": "6.1.0", 44 | "eslint-plugin-import": "2.24.2", 45 | "eslint-plugin-jsx-a11y": "6.4.1", 46 | "eslint-plugin-prettier": "4.0.0", 47 | "eslint-plugin-react": "7.26.1", 48 | "eslint-plugin-react-hooks": "4.2.0", 49 | "identity-obj-proxy": "3.0.0", 50 | "jest": "27.2.5", 51 | "prettier": "2.4.1", 52 | "rimraf": "3.0.2", 53 | "ts-jest": "^27.0.5", 54 | "typescript": "4.4.3", 55 | "vite": "^2.6.5", 56 | "vite-plugin-markdown": "^2.0.2" 57 | }, 58 | "jest": { 59 | "preset": "ts-jest", 60 | "testEnvironment": "jsdom", 61 | "testMatch": [ 62 | "/src/**/*.test.(ts|tsx)" 63 | ], 64 | "moduleFileExtensions": [ 65 | "ts", 66 | "tsx", 67 | "js", 68 | "jsx", 69 | "json", 70 | "node" 71 | ], 72 | "moduleNameMapper": { 73 | "^@/(.*)$": "/src/$1", 74 | "\\.css$": "identity-obj-proxy" 75 | }, 76 | "setupFilesAfterEnv": [ 77 | "/setup-test.ts" 78 | ], 79 | "coveragePathIgnorePatterns": [ 80 | "setup-test.ts", 81 | ".*/__tests__/.*" 82 | ], 83 | "globals": { 84 | "ts-jest": { 85 | "isolatedModules": true 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "automerge": true 4 | } 5 | -------------------------------------------------------------------------------- /setup-test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import '@testing-library/jest-dom/extend-expect'; 3 | -------------------------------------------------------------------------------- /src/App.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Sizes } from './theme.css'; 3 | 4 | export const pageWrapper = style({ 5 | width: '100%', 6 | maxWidth: `${Sizes.md}px`, 7 | }); 8 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, lazy } from 'react'; 2 | import { Route, Switch } from 'react-router-dom'; 3 | 4 | import { CounterProvider } from '@/state/useCounter'; 5 | import Navigation from '@/components/Navigation'; 6 | import Spinner from '@/components/Spinner'; 7 | import * as styles from './App.css'; 8 | 9 | const CounterPage = lazy(() => import('@/pages/Counter')); 10 | const AboutPage = lazy(() => import('@/pages/About')); 11 | const MissingPage = lazy(() => import('@/pages/Missing')); 12 | 13 | const App: React.FC = () => ( 14 | 15 |
16 | 17 | }> 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 | ); 28 | 29 | export default App; 30 | -------------------------------------------------------------------------------- /src/__tests__/About.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderWithRouter } from './helpers'; 3 | 4 | import App from '@/App'; 5 | 6 | jest.mock('../../README.md', () => ({ 7 | html: '

Content

', 8 | })); 9 | 10 | it('should render README content as markup', async () => { 11 | const root = await renderWithRouter(, { route: '/about', waitForId: 'about-page' }); 12 | expect(root.getByText('Content')).toBeInTheDocument(); 13 | }); 14 | 15 | it('should have a link back to github', async () => { 16 | const root = await renderWithRouter(, { route: '/about', waitForId: 'about-page' }); 17 | const link = root.getByText('View on GitHub'); 18 | expect(link.tagName).toBe('A'); 19 | expect((link as HTMLAnchorElement).href).toBe( 20 | 'https://github.com/drewschrauf/typescript-react-redux', 21 | ); 22 | expect((link as HTMLAnchorElement).target).toBe('_blank'); 23 | }); 24 | 25 | it('should update the title', async () => { 26 | document.title = 'Test'; 27 | await renderWithRouter(, { route: '/about', waitForId: 'about-page' }); 28 | expect(document.title).toBe('About | Test'); 29 | }); 30 | -------------------------------------------------------------------------------- /src/__tests__/Counter.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React from 'react'; 3 | import { fireEvent } from '@testing-library/react'; 4 | import { renderWithRouter } from './helpers'; 5 | 6 | import App from '@/App'; 7 | 8 | let err: typeof console.error; 9 | beforeEach(() => { 10 | err = console.error; 11 | console.error = jest.fn(); 12 | jest.useRealTimers(); 13 | }); 14 | 15 | afterEach(() => { 16 | console.error = err; 17 | }); 18 | 19 | it('should render current count', async () => { 20 | const root = await renderWithRouter(, { waitForId: 'counter-page' }); 21 | 22 | expect(root.getByText('Count 0')).toBeInTheDocument(); 23 | }); 24 | 25 | it('should render current count in title', async () => { 26 | document.title = 'Test'; 27 | await renderWithRouter(, { waitForId: 'counter-page' }); 28 | 29 | expect(document.title).toEqual('Count 0 | Test'); 30 | }); 31 | 32 | it('should render updated count in title', async () => { 33 | document.title = 'Test'; 34 | const root = await renderWithRouter(, { waitForId: 'counter-page' }); 35 | 36 | fireEvent.click(root.getByText('Increment by 1')); 37 | 38 | expect(document.title).toEqual('Count 1 | Test'); 39 | }); 40 | 41 | it('should revert title when unmounted', async () => { 42 | document.title = 'Test'; 43 | const root = await renderWithRouter(, { waitForId: 'counter-page' }); 44 | 45 | root.unmount(); 46 | 47 | expect(document.title).toEqual('Test'); 48 | }); 49 | 50 | it('should increment by default amount when increment button clicked', async () => { 51 | const root = await renderWithRouter(, { waitForId: 'counter-page' }); 52 | 53 | fireEvent.click(root.getByText('Increment by 1')); 54 | 55 | expect(root.getByText('Count 1')).toBeInTheDocument(); 56 | }); 57 | 58 | it('should decrement by default amount when decrement button clicked', async () => { 59 | const root = await renderWithRouter(, { waitForId: 'counter-page' }); 60 | 61 | fireEvent.click(root.getByText('Decrement by 1')); 62 | 63 | expect(root.getByText('Count -1')).toBeInTheDocument(); 64 | }); 65 | 66 | it('should delay increment by default amount when increment button clicked', async () => { 67 | const root = await renderWithRouter(, { waitForId: 'counter-page' }); 68 | jest.useFakeTimers(); 69 | 70 | fireEvent.click(root.getByText('Delayed increment by 1')); 71 | expect(root.getByText('Count 0')).toBeInTheDocument(); 72 | expect(root.getByText('Delayed increment by 1')).toBeDisabled(); 73 | 74 | await Promise.resolve().then(() => jest.runAllTimers()); 75 | 76 | expect(root.getByText('Count 1')).toBeInTheDocument(); 77 | expect(root.getByText('Delayed increment by 1')).not.toBeDisabled(); 78 | }); 79 | 80 | it('should increment by given amount when route provides increment amount', async () => { 81 | const root = await renderWithRouter(, { route: '/by/7', waitForId: 'counter-page' }); 82 | 83 | fireEvent.click(root.getByText('Increment by 7')); 84 | 85 | expect(root.getByText('Count 7')).toBeInTheDocument(); 86 | }); 87 | 88 | it('should decrement by given amount when route provides decrement amount', async () => { 89 | const root = await renderWithRouter(, { route: '/by/7', waitForId: 'counter-page' }); 90 | 91 | fireEvent.click(root.getByText('Decrement by 7')); 92 | 93 | expect(root.getByText('Count -7')).toBeInTheDocument(); 94 | }); 95 | 96 | it('should delay increment by given amount when route provides increment amount', async () => { 97 | const root = await renderWithRouter(, { route: '/by/7', waitForId: 'counter-page' }); 98 | jest.useFakeTimers(); 99 | 100 | fireEvent.click(root.getByText('Delayed increment by 7')); 101 | expect(root.getByText('Count 0')).toBeInTheDocument(); 102 | expect(root.getByText('Delayed increment by 7')).toBeDisabled(); 103 | 104 | await Promise.resolve().then(() => jest.runAllTimers()); 105 | 106 | expect(root.getByText('Count 7')).toBeInTheDocument(); 107 | expect(root.getByText('Delayed increment by 7')).not.toBeDisabled(); 108 | }); 109 | 110 | it('should show error message if invalid increment amount is given', async () => { 111 | const root = await renderWithRouter(, { route: '/by/garbage', waitForId: 'error-page' }); 112 | 113 | expect(root.getByText('You can\'t use "garbage" as an increment amount!')).toBeInTheDocument(); 114 | }); 115 | -------------------------------------------------------------------------------- /src/__tests__/Missing.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderWithRouter } from './helpers'; 3 | 4 | import App from '@/App'; 5 | 6 | it('should show error message', async () => { 7 | const root = await renderWithRouter(, { route: '/missing', waitForId: 'missing-page' }); 8 | expect(root.getByText("There's nothing here")).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/Navigation.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { fireEvent } from '@testing-library/react'; 3 | import { renderWithRouter } from './helpers'; 4 | 5 | import App from '@/App'; 6 | 7 | jest.mock('../../README.md', () => '

Content

'); 8 | 9 | it('should navigate from home to about', async () => { 10 | const root = await renderWithRouter(, { waitForId: 'counter-page' }); 11 | 12 | fireEvent.click(root.getByText('About')); 13 | 14 | const aboutPage = await root.findByTestId('about-page'); 15 | expect(aboutPage).toBeInTheDocument(); 16 | }); 17 | 18 | it('should navigate from about to home', async () => { 19 | const root = await renderWithRouter(, { route: '/about', waitForId: 'about-page' }); 20 | 21 | fireEvent.click(root.getByText('Count')); 22 | 23 | const counterPage = await root.findByTestId('counter-page'); 24 | expect(counterPage).toBeInTheDocument(); 25 | }); 26 | 27 | it('should navigate from home to count by', async () => { 28 | const root = await renderWithRouter(, { waitForId: 'counter-page' }); 29 | 30 | fireEvent.click(root.getByText('By 3')); 31 | 32 | expect(root.getByText('Increment by 3')).toBeInTheDocument(); 33 | }); 34 | 35 | it('should navigate from about to count by', async () => { 36 | const root = await renderWithRouter(, { route: '/about', waitForId: 'about-page' }); 37 | 38 | fireEvent.click(root.getByText('By 3')); 39 | 40 | await root.findByTestId('counter-page'); 41 | expect(root.getByText('Increment by 3')).toBeInTheDocument(); 42 | }); 43 | -------------------------------------------------------------------------------- /src/__tests__/helpers.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import React from 'react'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | import { Router } from 'react-router-dom'; 5 | import { createMemoryHistory } from 'history'; 6 | 7 | export const renderWithRouter = async ( 8 | ui: React.ReactElement, 9 | { route = '/', waitForId }: { route?: string; waitForId?: string } = {}, 10 | ): Promise => { 11 | const history = createMemoryHistory({ initialEntries: [route] }); 12 | const root = render({ui}); 13 | if (waitForId) { 14 | await root.findByTestId(waitForId); 15 | } 16 | return root; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/Counter.css.ts: -------------------------------------------------------------------------------- 1 | import { Sizes } from '@/theme.css'; 2 | import { style } from '@vanilla-extract/css'; 3 | 4 | export const wrapper = style({ 5 | padding: '10px', 6 | border: '1px solid black', 7 | }); 8 | 9 | export const buttonWrapper = style({ 10 | display: 'flex', 11 | flexDirection: 'column', 12 | '@media': { 13 | [`screen and (min-width: ${Sizes.sm}px)`]: { 14 | flexDirection: 'row', 15 | }, 16 | }, 17 | }); 18 | 19 | export const button = style({ 20 | flex: 1, 21 | flexBasis: '32px', 22 | fontFamily: 'inherit', 23 | 24 | selectors: { 25 | '&:not(:last-child)': { 26 | margin: '0 0 5px', 27 | }, 28 | }, 29 | 30 | '@media': { 31 | [`screen and (min-width: ${Sizes.sm}px)`]: { 32 | flexBasis: 'initial', 33 | height: '32px', 34 | selectors: { 35 | '&:not(:last-child)': { 36 | margin: '0 10px 0 0', 37 | }, 38 | }, 39 | }, 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import useCounter from '@/state/useCounter'; 4 | import * as styles from './Counter.css'; 5 | 6 | interface CounterProps { 7 | /** The amount to increment or decrement the counter by on each click */ 8 | readonly amount: number; 9 | } 10 | 11 | /** 12 | * React component that renders the main UI. It displays the current count and provides buttons 13 | * for modifying it. 14 | */ 15 | const Counter: React.FC = ({ amount }) => { 16 | const [state, actions] = useCounter(); 17 | return ( 18 |
19 |

Count {state.count}

20 |
21 | 24 | 27 | 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default Counter; 40 | -------------------------------------------------------------------------------- /src/components/Navigation.css.ts: -------------------------------------------------------------------------------- 1 | import { vars } from '@/theme.css'; 2 | import { style } from '@vanilla-extract/css'; 3 | 4 | export const wrapper = style({ 5 | marginBottom: '10px', 6 | }); 7 | 8 | export const dropdownLink = style({ 9 | selectors: { 10 | '&:after': { 11 | content: "'▼'", 12 | }, 13 | }, 14 | }); 15 | 16 | export const linkWrapper = style({ 17 | position: 'relative', 18 | display: 'inline-block', 19 | marginRight: '5px', 20 | }); 21 | 22 | export const linkList = style({ 23 | position: 'absolute', 24 | border: '1px solid black', 25 | backgroundColor: vars.color.background, 26 | 27 | padding: '10px', 28 | width: '70px', 29 | margin: '0', 30 | 31 | visibility: 'hidden', 32 | opacity: '0', 33 | transition: 'all 0.3s ease', 34 | 35 | selectors: { 36 | [`${linkWrapper}:hover &`]: { 37 | visibility: 'visible', 38 | opacity: '1', 39 | }, 40 | }, 41 | }); 42 | 43 | export const linkListItem = style({ 44 | listStyle: 'none', 45 | }); 46 | -------------------------------------------------------------------------------- /src/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import * as styles from './Navigation.css'; 4 | 5 | const Navigation: React.FC = () => ( 6 |
7 |
8 | 9 | Count 10 | 11 |
    12 | {[1, 2, 3, 5, 8].map((increment) => ( 13 |
  • 14 | By {increment} 15 |
  • 16 | ))} 17 |
18 |
19 |
20 | About 21 |
22 |
23 | ); 24 | export default Navigation; 25 | -------------------------------------------------------------------------------- /src/components/Spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as styles from './styles.css'; 3 | import withDelay from '@/hoc/withDelay'; 4 | 5 | const Spinner: React.FC = () =>
; 6 | 7 | export default withDelay({ delay: 500 })(Spinner); 8 | -------------------------------------------------------------------------------- /src/components/Spinner/styles.css.ts: -------------------------------------------------------------------------------- 1 | import { keyframes, style } from '@vanilla-extract/css'; 2 | 3 | const spin = keyframes({ 4 | '0%': { 5 | transform: 'rotate(0deg)', 6 | }, 7 | '100%': { 8 | transform: 'rotate(360deg)', 9 | }, 10 | }); 11 | 12 | export const spinner = style({ 13 | width: '64px', 14 | height: '64px', 15 | margin: '0 auto', 16 | 17 | ':after': { 18 | content: ' ', 19 | display: 'block', 20 | width: '46px', 21 | height: '46px', 22 | margin: '1px', 23 | borderRadius: '50%', 24 | border: '5px solid black', 25 | borderColor: 'black transparent black transparent', 26 | animation: `${spin} 1.2s linear infinite`, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/__tests__/Spinner.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, act } from '@testing-library/react'; 3 | 4 | import Spinner from '../Spinner'; 5 | 6 | jest.useFakeTimers(); 7 | 8 | it('should not render anything on initial render', () => { 9 | const root = render(); 10 | expect(root.container.innerHTML).toBe(''); 11 | }); 12 | 13 | it('should render spinner after a timeout', () => { 14 | const root = render(); 15 | act(() => { 16 | jest.runAllTimers(); 17 | }); 18 | expect(root.getByTestId('spinner')).toBeInTheDocument(); 19 | }); 20 | -------------------------------------------------------------------------------- /src/errors/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | export class BaseError extends Error {} 3 | export class ParseError extends BaseError {} 4 | -------------------------------------------------------------------------------- /src/hoc/__tests__/withDelay.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { act } from 'react-dom/test-utils'; 3 | import { render } from '@testing-library/react'; 4 | 5 | import withDelay from '../withDelay'; 6 | 7 | const DelayedElement = withDelay({ delay: 500 })(() =>

Element

); 8 | 9 | jest.useFakeTimers(); 10 | 11 | afterEach(() => { 12 | jest.restoreAllMocks(); 13 | }); 14 | 15 | it('should not render anything by default', () => { 16 | const root = render(); 17 | expect(root.container.innerHTML).toBe(''); 18 | }); 19 | 20 | it('should render element after timeout', () => { 21 | const root = render(); 22 | act(() => { 23 | jest.runAllTimers(); 24 | }); 25 | expect(root.getByText('Element')).toBeInTheDocument(); 26 | }); 27 | 28 | it('should cancel timer if unmounted before timer expires', () => { 29 | jest.spyOn(global, 'clearTimeout'); 30 | const root = render(); 31 | root.unmount(); 32 | act(() => { 33 | jest.runAllTimers(); 34 | }); 35 | expect(clearTimeout).toHaveBeenCalled(); 36 | }); 37 | -------------------------------------------------------------------------------- /src/hoc/__tests__/withErrorBoundary.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React from 'react'; 3 | import { render } from '@testing-library/react'; 4 | import withErrorBoundary from '../withErrorBoundary'; 5 | 6 | import { BaseError } from '@/errors'; 7 | 8 | class TestError extends BaseError {} 9 | 10 | let err: typeof console.error; 11 | beforeEach(() => { 12 | err = console.error; 13 | console.error = jest.fn(); 14 | }); 15 | 16 | afterEach(() => { 17 | console.error = err; 18 | }); 19 | 20 | it('should show provided component if no error', () => { 21 | const Element = withErrorBoundary()(() =>

Element

); 22 | const root = render(); 23 | expect(root.getByText('Element')).toBeInTheDocument(); 24 | }); 25 | 26 | it('should show error message if error', () => { 27 | const Element = withErrorBoundary()(() => ( 28 |

29 | {((): JSX.Element => { 30 | throw new Error('Basic error'); 31 | })()} 32 |

33 | )); 34 | const root = render(); 35 | expect(root.getByText('Something went wrong')).toBeInTheDocument(); 36 | expect(console.error).toHaveBeenCalled(); 37 | }); 38 | 39 | it('should show customised error if error extends BaseError', () => { 40 | const Element = withErrorBoundary()(() => ( 41 |

42 | {((): JSX.Element => { 43 | throw new TestError('Customised error'); 44 | })()} 45 |

46 | )); 47 | const root = render(); 48 | expect(root.getByText('Customised error')).toBeInTheDocument(); 49 | expect(console.error).toHaveBeenCalled(); 50 | }); 51 | -------------------------------------------------------------------------------- /src/hoc/withDelay.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | const useDelay = (ms: number): boolean => { 4 | const [delayComplete, setDelayComplete] = useState(false); 5 | useEffect(() => { 6 | const timer = setTimeout(() => { 7 | setDelayComplete(true); 8 | }, ms); 9 | return (): void => { 10 | clearTimeout(timer); 11 | }; 12 | }, [ms]); 13 | return delayComplete; 14 | }; 15 | 16 | interface WithDelayOptions { 17 | /** Time to delay render in ms */ 18 | delay: number; 19 | } 20 | 21 | /** Delays rendering of a component by a given time */ 22 | const withDelay = 23 | ({ delay }: WithDelayOptions) => 24 | (Comp: React.ComponentType): React.ComponentType => { 25 | const Delay: React.FC = (props) => { 26 | const delayComplete = useDelay(delay); 27 | // eslint-disable-next-line react/jsx-props-no-spreading 28 | return delayComplete ? : null; 29 | }; 30 | return Delay; 31 | }; 32 | 33 | export default withDelay; 34 | -------------------------------------------------------------------------------- /src/hoc/withErrorBoundary.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | 3 | export const errorMessageStyle = style({ 4 | border: '1px solid red', 5 | padding: '10px', 6 | }); 7 | -------------------------------------------------------------------------------- /src/hoc/withErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { BaseError } from '@/errors'; 4 | import { errorMessageStyle } from './withErrorBoundary.css'; 5 | 6 | interface ErrorBoundaryState { 7 | hasError: boolean; 8 | message?: string; 9 | } 10 | 11 | const errorBoundary = 12 | () => 13 | (Component: React.ComponentType): React.ComponentClass => { 14 | return class ErrorBoundary extends React.Component { 15 | constructor(props: T) { 16 | super(props); 17 | this.state = { hasError: false }; 18 | } 19 | 20 | static getDerivedStateFromError(error: Error): ErrorBoundaryState { 21 | let state: ErrorBoundaryState = { hasError: true }; 22 | if (error instanceof BaseError) { 23 | state = { ...state, message: error.message }; 24 | } 25 | return state; 26 | } 27 | 28 | render(): JSX.Element { 29 | const { hasError, message } = this.state; 30 | return !hasError ? ( 31 | // eslint-disable-line react/jsx-props-no-spreading 32 | ) : ( 33 |
34 | {message || 'Something went wrong'} 35 |
36 | ); 37 | } 38 | }; 39 | }; 40 | 41 | export default errorBoundary; 42 | -------------------------------------------------------------------------------- /src/hooks/useUpdateTitle.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | const useUpdateTitle = (title: string): void => { 4 | useEffect(() => { 5 | const originalTitle = document.title; 6 | document.title = `${title} | ${originalTitle}`; 7 | return (): void => { 8 | document.title = originalTitle; 9 | }; 10 | }, [title]); 11 | }; 12 | export default useUpdateTitle; 13 | -------------------------------------------------------------------------------- /src/index.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle, style } from '@vanilla-extract/css'; 2 | import { vars } from './theme.css'; 3 | 4 | globalStyle('*, *:before, *:after', { 5 | boxSizing: 'border-box', 6 | }); 7 | 8 | globalStyle('body', { 9 | width: '100%', 10 | backgroundColor: vars.color.background, 11 | fontFamily: vars.font.primary, 12 | margin: '0', 13 | padding: '10px', 14 | }); 15 | 16 | globalStyle('a, a:visited', { 17 | color: vars.color.link, 18 | textDecoration: 'none', 19 | }); 20 | globalStyle('a:hover', { 21 | textDecoration: 'underline', 22 | }); 23 | 24 | export const root = style({ 25 | width: '100%', 26 | display: 'flex', 27 | justifyContent: 'center', 28 | }); 29 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy, Suspense } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter as Router } from 'react-router-dom'; 4 | 5 | import 'normalize.css'; 6 | import * as styles from './index.css'; 7 | import Spinner from '@/components/Spinner'; 8 | 9 | const root = document.getElementById('root'); 10 | if (!root) { 11 | throw new Error('#root not found'); 12 | } 13 | root.classList.add(styles.root); 14 | 15 | const App = lazy(() => import('./App')); 16 | ReactDOM.render( 17 | 18 | }> 19 | 20 | 21 | , 22 | root, 23 | ); 24 | -------------------------------------------------------------------------------- /src/pages/About.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger */ 2 | import React from 'react'; 3 | 4 | import withErrorBoundary from '@/hoc/withErrorBoundary'; 5 | import useUpdateTitle from '@/hooks/useUpdateTitle'; 6 | import { html as readme } from '../../README.md'; 7 | 8 | const AboutPage: React.FC = () => { 9 | useUpdateTitle('About'); 10 | 11 | return ( 12 |
13 |
14 |

15 | 20 | View on GitHub 21 | 22 |

23 |
24 | ); 25 | }; 26 | export default withErrorBoundary()(AboutPage); 27 | -------------------------------------------------------------------------------- /src/pages/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Counter from '@/components/Counter'; 4 | import withErrorBoundary from '@/hoc/withErrorBoundary'; 5 | import { ParseError } from '@/errors'; 6 | import useCounter from '@/state/useCounter'; 7 | import useUpdateTitle from '@/hooks/useUpdateTitle'; 8 | 9 | const getAmount = (by = '1'): number => { 10 | const value = parseInt(by, 10); 11 | if (Number.isNaN(value)) { 12 | throw new ParseError(`You can't use "${by}" as an increment amount!`); 13 | } 14 | return value; 15 | }; 16 | 17 | interface CounterPageProps { 18 | match: { params: { by?: string } }; 19 | } 20 | 21 | const CounterPage: React.FC = ({ match }) => { 22 | const [{ count }] = useCounter(); 23 | useUpdateTitle(`Count ${count}`); 24 | return ( 25 |
26 | 27 |
28 | ); 29 | }; 30 | export default withErrorBoundary()(CounterPage); 31 | -------------------------------------------------------------------------------- /src/pages/Missing.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Missing: React.FC = () =>

There's nothing here

; 4 | export default Missing; 5 | -------------------------------------------------------------------------------- /src/state/__tests__/createContextualReducer.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React from 'react'; 3 | import { render } from '@testing-library/react'; 4 | import createContextualReducer from '../createContextualReducer'; 5 | 6 | let err: typeof console.error; 7 | beforeEach(() => { 8 | err = console.error; 9 | console.error = jest.fn(); 10 | }); 11 | 12 | afterEach(() => { 13 | console.error = err; 14 | }); 15 | 16 | it('should throw if using hook without provider', () => { 17 | const { useContextualReducer } = createContextualReducer( 18 | {}, 19 | () => ({}), 20 | () => ({}), 21 | ); 22 | 23 | const Comp: React.FC = () => { 24 | useContextualReducer(); 25 | return null; 26 | }; 27 | 28 | expect(() => { 29 | render(); 30 | }).toThrowError('hook must be used within the corresponding provider'); 31 | }); 32 | -------------------------------------------------------------------------------- /src/state/createContextualReducer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface Action { 4 | /** Action type */ 5 | readonly type: symbol; 6 | /** Action payload */ 7 | readonly payload: T; 8 | } 9 | 10 | interface ActionCreator { 11 | /** Type of created action */ 12 | readonly type: symbol; 13 | /** Given a payload, create an action */ 14 | (payload: T): Action; 15 | } 16 | 17 | export const createAction = (constant?: string): ActionCreator => { 18 | const type = Symbol(constant); 19 | return Object.assign((payload: T) => ({ type, payload }), { 20 | type, 21 | }); 22 | }; 23 | 24 | export const isType = ( 25 | action: Action, 26 | actionCreator: ActionCreator, 27 | ): action is Action => action.type === actionCreator.type; 28 | 29 | const createContextualReducer = ( 30 | initialState: State, 31 | reducer: (state: State, action: Action) => State, 32 | bindActions: (dispatch: React.Dispatch>) => Actions, 33 | ): { 34 | Provider: React.ComponentType; 35 | useContextualReducer: () => [State, Actions]; 36 | } => { 37 | type ContextValue = [State, Actions]; 38 | 39 | const Context = React.createContext(undefined as unknown as ContextValue); 40 | 41 | const Provider: React.FC = (props: T) => { 42 | const [currentState, dispatch] = React.useReducer(reducer, initialState); 43 | const value = React.useMemo( 44 | () => [currentState, bindActions(dispatch)], 45 | [currentState, dispatch], 46 | ); 47 | // eslint-disable-next-line react/jsx-props-no-spreading 48 | return ; 49 | }; 50 | 51 | const useContextualReducer = (): ContextValue => { 52 | const context = React.useContext(Context); 53 | if (!context) { 54 | throw new Error('hook must be used within the corresponding provider'); 55 | } 56 | return context; 57 | }; 58 | 59 | return { Provider, useContextualReducer }; 60 | }; 61 | export default createContextualReducer; 62 | -------------------------------------------------------------------------------- /src/state/useCounter.tsx: -------------------------------------------------------------------------------- 1 | import createContextualReducer, { createAction, isType } from './createContextualReducer'; 2 | 3 | const incrementBy = createAction<{ amount: number }>('INCREMENT'); 4 | const decrementBy = createAction<{ amount: number }>('DECREMENT'); 5 | const beginDelayedIncrement = createAction('BEGIN_DELAYED_INCREMENT'); 6 | const completeDelayedIncrement = createAction<{ amount: number }>('COMPLETE_DELAYED_INCREMENT'); 7 | 8 | const { Provider, useContextualReducer } = createContextualReducer( 9 | { count: 0, pending: false }, 10 | (state, action) => { 11 | if (isType(action, incrementBy)) { 12 | return { ...state, count: state.count + action.payload.amount }; 13 | } 14 | if (isType(action, decrementBy)) { 15 | return { ...state, count: state.count - action.payload.amount }; 16 | } 17 | if (isType(action, beginDelayedIncrement)) { 18 | return { ...state, pending: true }; 19 | } 20 | /* istanbul ignore else */ 21 | if (isType(action, completeDelayedIncrement)) { 22 | return { ...state, pending: false, count: state.count + action.payload.amount }; 23 | } 24 | /* istanbul ignore next */ 25 | return state; 26 | }, 27 | (dispatch) => ({ 28 | incrementBy: (amount: number): void => dispatch(incrementBy({ amount })), 29 | decrementBy: (amount: number): void => dispatch(decrementBy({ amount })), 30 | delayedIncrementBy: async (amount: number): Promise => { 31 | dispatch(beginDelayedIncrement(undefined)); 32 | await new Promise((resolve) => setTimeout(resolve, 500)); 33 | dispatch(completeDelayedIncrement({ amount })); 34 | }, 35 | }), 36 | ); 37 | Provider.displayName = 'CounterProvider'; 38 | export const CounterProvider = Provider; 39 | export default useContextualReducer; 40 | -------------------------------------------------------------------------------- /src/theme.css.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalTheme } from '@vanilla-extract/css'; 2 | 3 | export const vars = createGlobalTheme(':root', { 4 | color: { 5 | background: '#FBFBFB', 6 | link: '#0000EE', 7 | }, 8 | font: { 9 | primary: 'Arial', 10 | }, 11 | }); 12 | 13 | export enum Sizes { 14 | sm = 576, 15 | md = 768, 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@/*": ["src/*"] 6 | }, 7 | "noEmit": true, 8 | "module": "esnext", 9 | "moduleResolution": "node", 10 | "esModuleInterop": true, 11 | "isolatedModules": true, 12 | "allowJs": false, 13 | "strict": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "jsx": "react", 17 | "lib": ["dom", "es2015"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /types/markdown.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.md'; 2 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import reactRefresh from '@vitejs/plugin-react-refresh'; 4 | import markdown, { Mode } from 'vite-plugin-markdown'; 5 | import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | reactRefresh(), 10 | markdown({ mode: [Mode.HTML] }), 11 | vanillaExtractPlugin({ devStyleRuntime: 'vanilla-extract' }), 12 | ], 13 | resolve: { 14 | alias: { '@': path.resolve(__dirname, 'src') }, 15 | }, 16 | }); 17 | --------------------------------------------------------------------------------