Skip to main content

Overview

DevJobs uses a custom hooks-based state management approach instead of traditional solutions like Redux or Context API. State is synchronized with URL search parameters, making it:
  • Shareable: Users can share filtered search results via URLs
  • Bookmarkable: Search states can be saved as bookmarks
  • History-aware: Browser back/forward buttons work naturally
  • Deep-linkable: Direct access to any application state

State Management Philosophy

URL as Single Source of Truth

Search parameters in the URL drive application state

Custom Hooks for Logic

Reusable hooks encapsulate state and side effects

Local State Only

No global state management - components own their state

Declarative Updates

State changes automatically sync to URL and trigger effects

Custom Hooks

DevJobs includes three primary custom hooks:

1. useFilters

Manages filter state, pagination, and API requests for job listings. Location: src/hooks/useFilters.js:6
useFilters.js
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'

const RESULTS_PER_PAGE = 4

export const useFilters = () => {
  const [searchParams, setSearchParams] = useSearchParams()
  
  // Initialize filters from URL
  const [filters, setFilters] = useState(() => ({
    technology: searchParams.get('technology') || '',
    location: searchParams.get('type') || '',
    experienceLevel: searchParams.get('level') || ''
  }))
  
  const [textToFilter, setTextToFilter] = useState(
    () => searchParams.get('text') || ''
  )
  
  const [currentPage, setCurrentPage] = useState(() => {
    const page = searchParams.get('page') || '1'
    const pageNumber = Number(page)
    return isNaN(pageNumber) || pageNumber < 1 ? 1 : pageNumber
  })
  
  // API state
  const [jobs, setJobs] = useState([])
  const [total, setTotal] = useState(0)
  const [loading, setLoading] = useState(true)
  
  // ... (API fetching and URL sync logic)
  
  return {
    jobs,
    loading,
    total,
    totalPages,
    currentPage,
    textToFilter,
    handlePageChange,
    handleSearch,
    handleTextFilter
  }
}

2. useRouter

Provides navigation utilities wrapping React Router hooks. Location: src/hooks/useRouter.js:3
useRouter.js
import { useNavigate, useLocation } from 'react-router-dom'

export function useRouter() {
  const navigate = useNavigate()
  const location = useLocation()
  
  function navigateTo(path) {
    navigate(path)
  }
  
  return {
    currentPath: location.pathname,
    navigateTo
  }
}

3. useSearchForm

Manages search form state with debounced text input and filter synchronization. Location: src/hooks/useSearchForm.js:4
useSearchForm.js
import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'

export const useSearchForm = ({
  onTextFilter,
  onSearch,
  idTechnology,
  idLocation,
  idExperienceLevel,
  idText
}) => {
  const [searchParams] = useSearchParams()
  const timeoutId = useRef(null)
  
  // Refs for form elements
  const inputRef = useRef(null)
  const selectRefTechnology = useRef(null)
  const selectRefLocation = useRef(null)
  const selectRefExperienceLevel = useRef(null)
  
  const [searchText, setSearchText] = useState('')
  
  // ... (handlers and URL sync)
  
  return {
    searchText,
    handleSubmit,
    handleTextChange,
    handleClearFilters,
    inputRef,
    selectRefTechnology,
    selectRefLocation,
    selectRefExperienceLevel
  }
}

URL Synchronization Patterns

Reading from URL

Initialize state from URL parameters using lazy initialization:
const [filters, setFilters] = useState(() => ({
  technology: searchParams.get('technology') || '',
  location: searchParams.get('type') || '',
  experienceLevel: searchParams.get('level') || ''
}))
Using a function in useState ensures URL is only read once on mount, not on every render.

Writing to URL

Synchronize state changes back to URL using useEffect:
useFilters.js
useEffect(() => {
  setSearchParams(() => {
    const params = new URLSearchParams()
    
    // Only add params with values
    if (textToFilter) params.set('text', textToFilter)
    if (filters.technology) params.set('technology', filters.technology)
    if (filters.location) params.set('type', filters.location)
    if (filters.experienceLevel) params.set('level', filters.experienceLevel)
    if (currentPage > 1) params.set('page', currentPage)
    
    return params
  })
}, [filters, textToFilter, currentPage, setSearchParams])
Only include parameters with values to keep URLs clean. Empty filters are omitted.

Bidirectional Sync Flow

Filter State Management

The useFilters hook demonstrates a complete state management pattern.

State Structure

// Filter criteria
const [filters, setFilters] = useState({
  technology: '',      // e.g., 'react', 'vue'
  location: '',        // Job type: 'remote', 'onsite'
  experienceLevel: ''  // e.g., 'junior', 'senior'
})

// Text search
const [textToFilter, setTextToFilter] = useState('')

// Pagination
const [currentPage, setCurrentPage] = useState(1)

// API response
const [jobs, setJobs] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)

API Integration

State changes trigger API requests via useEffect:
useFilters.js
useEffect(() => {
  async function fetchJobs() {
    try {
      setLoading(true)
      
      // Build query parameters from state
      const params = new URLSearchParams()
      if (textToFilter) params.append('text', textToFilter)
      if (filters.technology) params.append('technology', filters.technology)
      if (filters.location) params.append('type', filters.location)
      if (filters.experienceLevel) params.append('level', filters.experienceLevel)
      
      // Add pagination
      const offset = (currentPage - 1) * RESULTS_PER_PAGE
      params.append('limit', RESULTS_PER_PAGE)
      params.append('offset', offset)
      
      // Fetch jobs
      const response = await fetch(
        `https://jscamp-api.vercel.app/api/jobs?${params.toString()}`
      )
      const json = await response.json()
      
      setJobs(json.data)
      setTotal(json.total)
    } catch (error) {
      console.error('Error fetching jobs:', error)
    } finally {
      setLoading(false)
    }
  }
  
  fetchJobs()
}, [filters, textToFilter, currentPage])
The effect runs whenever filters, text, or page changes, keeping results synchronized with state.

State Update Handlers

useFilters.js
const handleSearch = (filters) => {
  setFilters(filters)
  setCurrentPage(1) // Reset to first page on filter change
}

const handleTextFilter = (newTextToFilter) => {
  setTextToFilter(newTextToFilter)
  setCurrentPage(1) // Reset to first page on search
}

const handlePageChange = (page) => {
  setCurrentPage(page)
}
Always reset currentPage to 1 when filters change to avoid showing empty results.

Usage in Components

Search.jsx
import { useFilters } from '@/hooks/useFilters.js'

function SearchPage() {
  const {
    jobs,
    loading,
    total,
    totalPages,
    currentPage,
    textToFilter,
    handlePageChange,
    handleSearch,
    handleTextFilter
  } = useFilters()
  
  return (
    <main>
      <SearchFormSection
        initialTextInput={textToFilter}
        onSearch={handleSearch}
        onTextFilter={handleTextFilter}
      />
      
      {loading ? (
        <JobCardSkeleton count={5} />
      ) : (
        <JobListings jobs={jobs} />
      )}
      
      <Pagination
        currentPage={currentPage}
        totalPages={totalPages}
        onPageChange={handlePageChange}
      />
    </main>
  )
}

Form State with useSearchForm

The useSearchForm hook manages complex form interactions.

Debounced Text Input

Text input is debounced to reduce API calls:
useSearchForm.js
const handleTextChange = (event) => {
  const text = event.target.value
  setSearchText(text)
  
  // Cancel previous timeout
  if (timeoutId.current) {
    clearTimeout(timeoutId.current)
  }
  
  // Debounce: wait 500ms before filtering
  timeoutId.current = setTimeout(() => {
    onTextFilter(text)
  }, 500)
}
500ms debounce provides responsive feedback while limiting API requests to one per half-second.

Form Submission

useSearchForm.js
const handleSubmit = (event) => {
  event.preventDefault()
  
  const formData = new FormData(event.currentTarget)
  
  // Don't submit on text input change
  if (event.target.name === idText) {
    return
  }
  
  const filters = {
    technology: formData.get(idTechnology),
    location: formData.get(idLocation),
    experienceLevel: formData.get(idExperienceLevel)
  }
  
  onSearch(filters)
}

Syncing Form Fields with URL

Form fields are synchronized when URL changes (e.g., browser back button):
useSearchForm.js
useEffect(() => {
  const tech = searchParams.get('technology') || ''
  const location = searchParams.get('type') || ''
  const level = searchParams.get('level') || ''
  
  if (selectRefTechnology.current) {
    selectRefTechnology.current.value = tech
  }
  if (selectRefLocation.current) {
    selectRefLocation.current.value = location
  }
  if (selectRefExperienceLevel.current) {
    selectRefExperienceLevel.current.value = level
  }
}, [searchParams])
This ensures form state stays in sync with URL when users navigate via browser controls.

Clear Filters

useSearchForm.js
const handleClearFilters = (event) => {
  event.preventDefault()
  
  // Clear state
  onTextFilter('')
  onSearch({
    technology: '',
    location: '',
    experienceLevel: ''
  })
  
  // Clear form fields
  inputRef.current.value = ''
  selectRefTechnology.current.value = ''
  selectRefLocation.current.value = ''
  selectRefExperienceLevel.current.value = ''
  setSearchText('')
}

State Flow Example

Here’s a complete flow when a user filters jobs:
1

User selects technology filter

User selects “React” from technology dropdown
2

Form submission

handleSubmit extracts form data and calls onSearch({ technology: 'react' })
3

State update

handleSearch in useFilters calls setFilters({ technology: 'react' }) and setCurrentPage(1)
4

URL synchronization

useEffect in useFilters detects filter change and updates URL to /search?technology=react
5

API request

Another useEffect detects filter change and fetches jobs with new parameters
6

UI update

Component re-renders with new jobs, loading state updates accordingly

Best Practices

Read URL parameters only once on mount:
const [state, setState] = useState(() => 
  searchParams.get('param') || defaultValue
)
Always reset to page 1 when filters change:
const handleSearch = (filters) => {
  setFilters(filters)
  setCurrentPage(1) // Important!
}
Reduce API calls by debouncing text search:
setTimeout(() => onTextFilter(text), 500)
Clear timeouts when component unmounts:
useEffect(() => {
  return () => {
    if (timeoutId.current) {
      clearTimeout(timeoutId.current)
    }
  }
}, [])
Keep URLs clean by excluding empty values:
if (value) params.set('key', value)

Advantages of This Approach

No Global State

Components remain independent and easier to test

Shareable URLs

Any application state can be shared via URL

Browser Integration

Back/forward buttons work naturally without extra code

Simple Mental Model

URL is the single source of truth - easy to reason about

Better Performance

No unnecessary re-renders from global context changes

Persistent State

State survives page refreshes via URL parameters

When to Use This Pattern

This URL-based state management pattern works best for:
  • Search and filter interfaces
  • Paginated lists
  • Multi-step forms
  • Dashboard configurations
  • Any state that should be shareable or bookmarkable
Don’t store sensitive data in URL parameters - they’re visible in browser history and logs.

Next Steps

Architecture Overview

Understand the overall application architecture

Routing

Learn about React Router setup and navigation

Build docs developers (and LLMs) love