Refactoring YappApp: A Journey from Spaghetti to Structure

When I first built YappApp, a social platform for daily discussion prompts, I was focused on getting features working. Authentication? Threw it in the components. State management? Sprinkled throughout the app. CSS? Copy-pasted wherever needed. It worked, but as the application grew, so did the technical debt.

Six months and countless "quick fixes" later, I realized I had created what developers fondly call "spaghetti code." Here's how I refactored it into something both maintainable and scalable.

The Initial State: A Tangled Web

Let's look at what I started with. Here's a snippet from an early version of my post creation component:

const CreatePost = () => {
  const [text, setText] = useState('')
  const [error, setError] = useState('')
  const [user, setUser] = useState(null)
  const [token, setToken] = useState('')
  const navigate = useNavigate()

  useEffect(() => {
    // Authentication logic mixed with component logic
    const token = localStorage.getItem('token')
    if (!token) {
      navigate('/')
      return
    }
    // Decode token and set user
    setToken(token)
    setUser(JSON.parse(atob(token.split('.')[1])))
  }, [])

  const handleSubmit = async (e) => {
    e.preventDefault()
    try {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({ text, user: user.id }),
      })
      // More directly coupled API logic...
    } catch (err) {
      setError(err.message)
    }
  }
  // ...rest of the component
}

The issues were clear:

  1. Authentication logic mixed with component logic
  2. Direct API calls within components
  3. Duplicated code across similar components
  4. No separation of concerns

The Refactoring Process

Step 1: Centralizing Authentication

First, I created a dedicated AuthContext to handle all authentication-related logic:

// src/contexts/AuthContext.jsx
export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null)
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    const currentUser = authService.getUser()
    if (currentUser) {
      setUser(currentUser)
    }
    setIsLoading(false)
  }, [])

  const value = {
    user,
    signin,
    signup,
    signout,
    updateUser,
    isLoading,
    isAuthenticated: !!user,
  }

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

This immediately cleaned up all components that needed authentication, reducing them to:

const { user } = useAuth()

Step 2: Creating a Service Layer

Next, I extracted all API calls into dedicated service files:

// src/services/postService.js
export const createPost = async (postData) => {
  try {
    const token = localStorage.getItem('token')
    const response = await fetch(`${BASE_URL}/posts`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify(postData),
    })

    const data = await response.json()
    if (!response.ok) {
      throw new Error(data.error || 'Failed to create post')
    }
    return data
  } catch (error) {
    throw error
  }
}

Step 3: Component Reorganization

I then refactored components to follow a clear, single-responsibility principle. The CreatePost component became:

const CreatePost = () => {
  const [text, setText] = useState('')
  const [error, setError] = useState('')
  const { user } = useAuth()
  const navigate = useNavigate()

  const handleSubmit = async (e) => {
    e.preventDefault()
    try {
      await postService.createPost({
        owner: user._id,
        prompt: promptId,
        text: text.trim(),
      })
      navigate(`/prompt/${promptId}`)
    } catch (err) {
      setError(err.message)
    }
  }

  return <div className="create-post-container">{/* Clean, focused JSX */}</div>
}

Step 4: Implementing Protected Routes

To handle authentication-based routing, I created a ProtectedRoute component:

const ProtectedRoute = ({ children }) => {
  const { user } = useAuth()

  if (!user) {
    return <Navigate to="/" replace />
  }

  return (
    <>
      <NavBar />
      {children}
      <Footer />
    </>
  )
}

The Results

After refactoring, the benefits were immediate:

  1. Reduced Code Duplication: Common functionality was centralized in services and contexts
  2. Improved Testing: With separated concerns, unit testing became straightforward
  3. Better Error Handling: Consistent error handling across the application
  4. Easier Maintenance: New team members could understand and modify code more easily
  5. Enhanced Performance: Reduced unnecessary re-renders and better state management

Key Lessons Learned

  1. Start with Structure: Even if you're prototyping, basic architecture decisions save time later
  2. Separate Concerns: Keep components focused on their primary responsibility
  3. Create Abstractions Early: Identify common patterns and create reusable solutions
  4. Document Decisions: Keep track of why you made certain architectural choices

Looking Forward

The refactoring process never really ends. As new features are added and requirements change, maintaining clean code is an ongoing process. However, with proper structure in place, these changes become evolutionary rather than revolutionary.

Is your code base feeling tangled? Start small. Pick one component or feature and refactor it following these principles. You'll be surprised how quickly small improvements add up to significant changes in maintainability and developer experience.

Want to see the complete refactored code? Check out the YappApp on GitHub.

Remember, good code isn't about perfection - it's about being better than yesterday.