Skip to content
FunDev
FunDev
react

Building a Precision Timer App with React 19, Vite 7, and PWA

Building a Precision Timer App with React 19, Vite 7, and PWA
5 views
13 min read
#react

My child learned cup stacking, or sport stacking, at school and wanted a timer to practice at home. Buying a dedicated timer felt hard to justify, and a smartphone stopwatch was awkward to operate.

So I decided to build a simple tablet timer that starts and stops when you touch anywhere on the screen. Making it a PWA would let us add it to the home screen, use it like an app, and run it offline.

Demo: cupstacking.funq.kr

Technology Stack

StackTechnology
FrameworkReact 19.2 + TypeScript 5.9
BuildVite 7.2 + vite-plugin-pwa
StylingTailwind CSS v4
RoutingReact Router v7

1. A requestAnimationFrame-Based Timer

The Limitations of setInterval

// ❌ 부정확한 방식
setInterval(() => {
  setTime(prev => prev + 10);
}, 10);

setInterval does not guarantee precise intervals. It is throttled when the browser tab goes into the background, and callback execution time accumulates, causing drift.

requestAnimationFrame + Date.now()

export type TimerState = 'idle' | 'running' | 'stopped';
 
export function useTimer() {
  const [time, setTime] = useState(0);
  const [state, setState] = useState<TimerState>('idle');
  const startTimeRef = useRef<number>(0);
  const rafRef = useRef<number>(0);
 
  useEffect(() => {
    if (state !== 'running') return;
 
    const updateTime = () => {
      const elapsed = Date.now() - startTimeRef.current;
      setTime(elapsed);
      rafRef.current = requestAnimationFrame(updateTime);
    };
 
    rafRef.current = requestAnimationFrame(updateTime);
 
    return () => {
      cancelAnimationFrame(rafRef.current);
    };
  }, [state]);
 
  const start = useCallback(() => {
    startTimeRef.current = Date.now();
    setState('running');
  }, []);
 
  const stop = useCallback(() => {
    cancelAnimationFrame(rafRef.current);
    const finalTime = Date.now() - startTimeRef.current;
    setTime(finalTime);
    setState('stopped');
    return finalTime;
  }, []);
 
  const reset = useCallback(() => {
    cancelAnimationFrame(rafRef.current);
    setTime(0);
    setState('idle');
  }, []);
 
  const toggle = useCallback(() => {
    if (state === 'idle') {
      start();
      return null;
    } else if (state === 'running') {
      return stop();
    }
    return null;
  }, [state, start, stop]);
 
  return { time, state, start, stop, reset, toggle };
}

Key points:

  • Calculate elapsed time from Date.now() → no cumulative timing error
  • requestAnimationFrame → synchronized with the display refresh rate, usually 60fps
  • Store the start time in useRef → unaffected by rerenders
  • toggle() → handle start and stop in one function, returning the final time on stop

Formatting Time

export function formatTime(ms: number): string {
  const seconds = Math.floor(ms / 1000);
  const milliseconds = ms % 1000;
  return `${seconds}.${milliseconds.toString().padStart(3, '0')}`;
}
// 12345 → "12.345"

2. A Complete Guide to PWA (Progressive Web App)

PWA technology lets an app built with web technologies be used like a native app. Here is why I applied it to this timer:

  1. Home-screen installation—launch immediately by tapping an icon
  2. Full screen—looks like an app without the browser address bar
  3. Offline support—works without internet access
  4. Automatic updates—new deployments are applied automatically

Configuring vite-plugin-pwa

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
 
export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
      manifest: {
        name: '컵쌓기 타이머',
        short_name: '컵타이머',
        description: '스포츠 스태킹 타이머 앱',
        theme_color: '#10b981',
        background_color: '#ffffff',
        display: 'standalone',
        orientation: 'portrait',
        icons: [
          {
            src: 'pwa-192x192.png',
            sizes: '192x192',
            type: 'image/png'
          },
          {
            src: 'pwa-512x512.png',
            sizes: '512x512',
            type: 'image/png'
          }
        ]
      }
    })
  ],
})

Manifest Properties

PropertyValueDescription
nameCup Stacking TimerFull name shown during app installation
short_nameCup TimerShown below the home-screen icon (12 characters or fewer recommended)
displaystandaloneHide the browser UI and display like a native app
orientationportraitLock to portrait mode for the timer's use case
theme_color#10b981Status-bar color (emerald-500)
background_color#ffffffSplash-screen background while the app loads

Comparing Display Modes

┌─────────────────────────────────────────────────────┐
│  fullscreen   │  standalone   │    browser         │
├───────────────┼───────────────┼────────────────────┤
│  상태바 숨김    │  상태바만 표시  │  브라우저 UI 전체   │
│  게임/몰입형    │  일반 앱       │  웹사이트          │
│  주소창 없음    │  주소창 없음    │  주소창 있음        │
└─────────────────────────────────────────────────────┘

Why I chose standalone: it shows the status bar, including the clock and battery, while hiding the browser UI.

registerType: autoUpdate

registerType: 'autoUpdate'

Service Worker update strategies:

StrategyBehavior
autoUpdateAutomatically update the Service Worker when a new version is detected
promptShow a UI asking whether the user wants to update

Why I chose autoUpdate: there is little concern about losing state in this timer app, and it is better to always use the latest version.

Preparing Icons

A PWA needs at least two icons:

public/
├── apple-touch-icon.png  # iOS Safari용 (180x180)
├── favicon.ico           # 브라우저 탭용
├── pwa-192x192.png       # Android 홈 화면용
└── pwa-512x512.png       # 스플래시 화면용

Generate them automatically from the source SVG using sharp:

import sharp from 'sharp';
 
const sizes = [192, 512];
const svgBuffer = await fs.readFile('public/icon.svg');
 
for (const size of sizes) {
  await sharp(svgBuffer)
    .resize(size, size)
    .png()
    .toFile(`public/pwa-${size}x${size}.png`);
}

Supporting iOS Safari

On iOS, specify apple-touch-icon separately:

<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />

Build Output

Files generated automatically when npm run build runs:

dist/
├── sw.js              # Service Worker
├── workbox-*.js       # Workbox 런타임
├── manifest.webmanifest
└── registerSW.js      # SW 등록 스크립트

3. Preventing Duplicate Touch and Click Events

On mobile, a touchend is followed by a click event after a 300ms delay. If both are handled, the timer toggles twice.

const touchHandledRef = useRef(false);
 
const handleTouchEnd = useCallback(
  (e: React.TouchEvent) => {
    e.preventDefault();
    touchHandledRef.current = true;
    handleTouch();
    setTimeout(() => {
      touchHandledRef.current = false;
    }, 300);
  },
  [handleTouch]
);
 
const handleClick = useCallback(() => {
  if (touchHandledRef.current) return; // 터치 후 클릭 무시
  handleTouch();
}, [handleTouch]);
<div
  className={`min-h-full ${bgColor} flex flex-col`}
  onClick={handleClick}
  onTouchEnd={handleTouchEnd}
>

Event flow:

  1. Touch → touchHandledRef = true → toggle the timer
  2. Click within 300ms → check touchHandledRef → ignore
  3. After 300ms → touchHandledRef = false → wait for the next input

4. A Custom LocalStorage Hook Pattern

Managing Players and Records

const PLAYERS_KEY = 'cupstacking_players';
const RECORDS_KEY = 'cupstacking_records';
 
function generateId(): string {
  return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
 
export function usePlayers() {
  const [players, setPlayers] = useState<Player[]>(() => {
    const stored = localStorage.getItem(PLAYERS_KEY);
    return stored ? JSON.parse(stored) : [];
  });
 
  useEffect(() => {
    localStorage.setItem(PLAYERS_KEY, JSON.stringify(players));
  }, [players]);
 
  const addPlayer = useCallback((name: string): Player => {
    const newPlayer: Player = {
      id: generateId(),
      name: name.trim(),
      createdAt: Date.now(),
    };
    setPlayers((prev) => [...prev, newPlayer]);
    return newPlayer;
  }, []);
 
  const removePlayer = useCallback((id: string) => {
    setPlayers((prev) => prev.filter((p) => p.id !== id));
  }, []);
 
  const getPlayer = useCallback(
    (id: string) => players.find((p) => p.id === id),
    [players]
  );
 
  return { players, addPlayer, removePlayer, getPlayer };
}

Design decisions:

  • Initial value: read localStorage in the useState initializer function (SSR-compatible)
  • Persistence: use useEffect to detect state changes and save automatically
  • ID generation: timestamp + random value to prevent collisions

Optimizing Record Lookups

const getBestRecord = useCallback(
  (eventType: TimeRecord['eventType'], playerIds: string[]) => {
    const key = [...playerIds].sort().join(',');
    return records
      .filter(
        (r) =>
          r.eventType === eventType &&
          [...r.playerIds].sort().join(',') === key
      )
      .sort((a, b) => a.time - b.time)[0];
  },
  [records]
);

Sort playerIds and convert them to a string for comparison → treat [A, B] and [B, A] as identical.


5. Domain Modeling with TypeScript

export type EventType = '3-3-3' | '3-6-3' | 'cycle' | 'doubles' | 'team-3-6-3';
 
export interface Player {
  id: string;
  name: string;
  createdAt: number;
}
 
export interface TimeRecord {
  id: string;
  eventType: EventType;
  playerIds: string[];
  time: number; // milliseconds
  createdAt: number;
}
 
export const INDIVIDUAL_EVENTS: EventType[] = ['3-3-3', '3-6-3', 'cycle'];
export const TEAM_EVENTS: EventType[] = ['doubles', 'team-3-6-3'];
 
export const EVENT_NAMES: { [key in EventType]: string } = {
  '3-3-3': '3-3-3',
  '3-6-3': '3-6-3',
  'cycle': '사이클',
  'doubles': '더블',
  'team-3-6-3': '팀 3-6-3',
};
 
export const EVENT_MIN_PLAYERS: { [key in EventType]: number } = {
  '3-3-3': 1,
  '3-6-3': 1,
  'cycle': 1,
  'doubles': 2,
  'team-3-6-3': 2,
};

{ [key in EventType]: string } → a value is required for every event. Adding an event causes a compile error if its value is missing.


6. Tailwind CSS v4 Configuration

@import "tailwindcss";
 
html, body, #root {
  height: 100%;
  margin: 0;
  padding: 0;
}
 
/* Prevent pull-to-refresh and overscroll */
html {
  overscroll-behavior: none;
}
 
/* Disable text selection on timer screen */
.no-select {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

Background Colors by State

const bgColor =
  state === 'idle'
    ? 'bg-white'
    : state === 'running'
    ? 'bg-emerald-400'
    : 'bg-amber-300';

Visual feedback: ready (white) → running (green) → finished (yellow)

Responsive Font Sizes

<p
  className="font-mono font-bold text-gray-800"
  style={{ fontSize: 'min(20vw, 120px)' }}
>
  {formatTime(time)}
</p>

min(20vw, 120px) → 20% of the viewport width, capped at 120px. It keeps the size appropriate from tablets to desktops.


7. URL Parameters with React Router v7

Route Definitions

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/select/:eventType" element={<PlayerSelect />} />
  <Route path="/timer/:eventType" element={<Timer />} />
  <Route path="/result/:eventType" element={<Result />} />
  <Route path="/ranking" element={<Ranking />} />
  <Route path="/players" element={<PlayerManage />} />
</Routes>

Combining Path and Query Parameters

const { eventType } = useParams<{ eventType: EventType }>();
const [searchParams] = useSearchParams();
 
const playerIds = useMemo(() => {
  const param = searchParams.get('players');
  return param ? param.split(',').filter(Boolean) : [];
}, [searchParams]);

URL: /timer/3-6-3?players=abc123,def456 → playerIds = ["abc123", "def456"]


8. Preventing Accidental Input

A One-Second Guard

Prevent children from accidentally stopping the timer immediately:

const handleTouch = useCallback(() => {
  if (state === 'stopped') return;
  // 1초 미만에서 정지 불가
  if (state === 'running' && time < 1000) return;
 
  const finalTime = toggle();
  if (finalTime !== null) {
    const record = addRecord(eventType as EventType, playerIds, finalTime);
    setSavedRecordId(record.id);
  }
}, [state, time, toggle, addRecord, eventType, playerIds]);

Delete a Record and Try Again

Delete an incorrectly measured record and restart:

const [savedRecordId, setSavedRecordId] = useState<string | null>(null);
 
const handleDeleteRecord = useCallback(() => {
  if (savedRecordId) {
    deleteRecord(savedRecordId);
    setSavedRecordId(null);
  }
  reset();
}, [savedRecordId, deleteRecord, reset]);

Keep the ID when saving a record → the delete button can remove only that record.


9. Nginx Deployment Configuration

This Nginx configuration deploys a Vite-built SPA to an Ubuntu server.

Static File Serving vs. Reverse Proxying

MethodPurposeExample
Static file servingVite/React SPAroot /path/to/dist
Reverse proxyingNext.js/Expressproxy_pass http://127.0.0.1:3000

Since this timer is a Vite SPA, Nginx serves the build output in dist/ directly.

Nginx Configuration File

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name cupstacking.funq.kr;
 
    # 빌드 결과물 경로
    root /home/funq/dev/cupstacking-timer/dist;
    index index.html;
 
    # SPA 라우팅: 모든 경로를 index.html로
    location / {
        try_files $uri $uri/ /index.html;
    }
 
    # 정적 에셋 캐싱 (1년)
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
 
    # Gzip 압축
    gzip on;
    gzip_types
        text/plain
        text/css
        application/json
        application/javascript
        text/xml
        application/xml
        text/javascript;
 
    # SSL 인증서 (Let's Encrypt)
    ssl_certificate /etc/letsencrypt/live/cupstacking.funq.kr/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cupstacking.funq.kr/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
 
# HTTP → HTTPS 리다이렉트
server {
    listen 80;
    listen [::]:80;
    server_name cupstacking.funq.kr;
 
    return 301 https://$host$request_uri;
}

Key Settings Explained

try_files (SPA Routing)

try_files $uri $uri/ /index.html;
RequestNginx behavior
/Serve dist/index.html
/assets/index.jsServe the requested file
/timer/3-6-3File not found → serve index.html → React Router handles the route

An SPA uses client-side routing, so even paths that do not exist as files must return index.html.

Caching Static Assets

location ~* \.(js|css|png)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

Vite includes a hash in filenames during the build, such as index-abc123.js. The hash changes with the content, so caching for one year is safe.

Gzip Compression

gzip on;
gzip_types text/css application/javascript;

Compresses 100KB of JavaScript to about 30KB, improving loading speed on mobile.

Deployment Commands

# 1. 빌드
npm run build
 
# 2. 서버에 업로드 (또는 git pull)
rsync -avz dist/ funq@server:/home/funq/dev/cupstacking-timer/dist/
 
# 3. Nginx 설정 테스트 및 재시작
sudo nginx -t
sudo systemctl reload nginx

Issuing a Let's Encrypt SSL Certificate

# Certbot 설치
sudo apt install certbot python3-certbot-nginx
 
# 인증서 발급 (Nginx 설정 자동 수정)
sudo certbot --nginx -d cupstacking.funq.kr
 
# 자동 갱신 확인
sudo certbot renew --dry-run

Summary

TechnologyApplication
requestAnimationFrameTimer without drift
useRefPrevent duplicate touch handling and store the start time
LocalStorage hookPersist players and records
TypeScript Literal TypesType safety for stacking events
Vite PWAOffline support and app installation
CSS min()Responsive Font Sizes
Nginx + Let's EncryptStatic SPA serving and HTTPS

My child now starts the timer by tapping the tablet and watches the records to see their progress. It is a simple app, but PWA made it possible to provide an experience on par with a native app.

Related posts

Comments

Korean and English pages share this conversation.

Write a comment

0 / 5,000
You will need this password to edit or delete this comment.

Loading comments…