Abhishek Anand

JavaScript/November 28, 2024/6 min read

The Evolution of JavaScript: From ES6 to ES2024

Exploring the latest JavaScript features and how they're changing the way we write modern web applications.

Abhishek Anand

Abhishek Anand

Senior UX Engineer at Google

JavaScript · ES2024 · Web Development · Frontend · Modern JS

Introduction

JavaScript has come a long way since ES6 (ECMAScript 2015) revolutionized the language nearly a decade ago. As someone who has been writing JavaScript professionally for over 17 years, I've witnessed this incredible evolution firsthand—from the jQuery days to building complex React applications at Google that serve millions of users.

Each year brings new features that make JavaScript more powerful, expressive, and developer-friendly. In this article, we'll explore the journey from ES6 to ES2024, highlighting the features that have fundamentally changed how we write modern JavaScript applications.

JavaScript Evolution Timeline (ES6 to ES2024)

ES6 (2015): The Foundation

ES6 was a watershed moment for JavaScript. It introduced features that we now consider essential:

Arrow Functions & Lexical This

Arrow functions and lexical scoping
// Before ES6
var self = this;
document.addEventListener('click', function(e) {
  self.handleClick(e);
});

// ES6 Arrow Functions
document.addEventListener('click', (e) => {
  this.handleClick(e); // 'this' is lexically bound
});

// Modern usage in React components
const useClickHandler = () => {
  const handleClick = useCallback((e) => {
    // Arrow functions make event handlers cleaner
    analytics.track('click', { target: e.target.tagName });
  }, []);
  
  return handleClick;
};

Template Literals

String interpolation evolution
// String concatenation evolution
const userName = 'Abhishek';
const campaignCount = 42;

// Before ES6
var message = 'Hello ' + userName + ', you have ' + campaignCount + ' campaigns';

// ES6 Template Literals
const message = `Hello ${userName}, you have ${campaignCount} campaigns`;

// Modern usage with multi-line strings
const htmlTemplate = `
  <div class="campaign-card">
    <h3>${campaign.name}</h3>
    <p>Impressions: ${campaign.impressions.toLocaleString()}</p>
    <p>CTR: ${(campaign.ctr * 100).toFixed(2)}%</p>
  </div>
`;

Destructuring & Rest Parameters

Destructuring and rest parameters
// Destructuring revolutionized data extraction
const campaign = {
  id: '12345',
  name: 'Black Friday Sale',
  metrics: { impressions: 100000, clicks: 5000 },
  settings: { budget: 1000, status: 'active' }
};

// Extract nested data elegantly
const { 
  name, 
  metrics: { impressions, clicks },
  settings: { status } 
} = campaign;

// Rest parameters for flexible functions
const createCampaign = (name, ...options) => {
  const [budget, audience, schedule] = options;
  return { name, budget, audience, schedule };
};

// Modern React component props
const CampaignCard = ({ 
  campaign: { name, metrics },
  onEdit,
  ...cardProps 
}) => (
  <div {...cardProps}>
    <h3>{name}</h3>
    <p>Impressions: {metrics.impressions}</p>
  </div>
);

ES2017: The Async/Await Revolution

Async/await transformed how we handle asynchronous JavaScript, making it readable and maintainable:

Evolution of Asynchronous JavaScript Patterns

The async/await transformation
// Before: Promise chain complexity
function fetchUserData(userId) {
  return fetch(`/api/users/${userId}`)
    .then(response => response.json())
    .then(user => fetch(`/api/users/${userId}/posts`)
      .then(response => response.json())
      .then(posts => ({ ...user, posts }))
    );
}

// After: Clean, readable async/await
async function fetchUserData(userId) {
  const userResponse = await fetch(`/api/users/${userId}`);
  const user = await userResponse.json();
  
  const postsResponse = await fetch(`/api/users/${userId}/posts`);
  const posts = await postsResponse.json();
  
  return { ...user, posts };
}

ES2018: Rest/Spread Everywhere

Object rest/spread properties made object manipulation much more elegant:

Object spread for immutable updates
// Object spread for immutable updates
const originalCampaign = {
  id: '123',
  name: 'Summer Sale',
  budget: 1000,
  status: 'draft'
};

// Update campaign immutably
const updatedCampaign = {
  ...originalCampaign,
  budget: 1500,
  status: 'active'
};

// Object rest for extracting props
const { status, ...campaignWithoutStatus } = originalCampaign;

// Practical React component example
const CampaignForm = ({ initialCampaign, onSave, ...formProps }) => {
  const [campaign, setCampaign] = useState({ ...initialCampaign });
  
  const updateField = (field, value) => {
    setCampaign(prev => ({ ...prev, [field]: value }));
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    const { id, ...campaignData } = campaign;
    onSave(campaignData);
  };
  
  return (
    <form onSubmit={handleSubmit} {...formProps}>
      {/* Form fields */}
    </form>
  );
};

ES2019: Quality of Life Improvements

ES2019 brought several utility methods that solve common problems:

Array.flat() and Object.fromEntries()
// Array.flat() for nested arrays
const campaignGroups = [
  ['search-campaigns', 'display-campaigns'],
  ['video-campaigns'],
  ['shopping-campaigns', 'app-campaigns']
];

const allCampaigns = campaignGroups.flat();
// ['search-campaigns', 'display-campaigns', 'video-campaigns', 'shopping-campaigns', 'app-campaigns']

// Object.fromEntries() for creating objects from key-value pairs
const campaignMetrics = [
  ['impressions', 100000],
  ['clicks', 5000],
  ['conversions', 250]
];

const metricsObject = Object.fromEntries(campaignMetrics);
// { impressions: 100000, clicks: 5000, conversions: 250 }

// Practical usage in data transformation
const transformCampaignData = (campaigns) => {
  return campaigns
    .map(campaign => campaign.adGroups.flat()) // Flatten ad groups
    .flat() // Flatten campaign arrays
    .map(adGroup => [adGroup.id, adGroup.performance])
    .pipe(Object.fromEntries); // Create performance lookup
};

// String.trimStart() and trimEnd()
const userInput = '  campaign name  ';
const cleanName = userInput.trimStart().trimEnd(); // or just trim()

ES2020: Big Features

Optional Chaining (?.) & Nullish Coalescing (??)

These features eliminated so much defensive coding:

Safe property access
// Before ES2020 - defensive coding everywhere
function getCampaignCTR(campaign) {
  if (campaign && 
      campaign.metrics && 
      campaign.metrics.clicks !== undefined && 
      campaign.metrics.impressions !== undefined &&
      campaign.metrics.impressions > 0) {
    return campaign.metrics.clicks / campaign.metrics.impressions;
  }
  return 0;
}

// ES2020 - clean and safe
function getCampaignCTR(campaign) {
  const clicks = campaign?.metrics?.clicks ?? 0;
  const impressions = campaign?.metrics?.impressions ?? 0;
  return impressions > 0 ? clicks / impressions : 0;
}

// Practical React component usage
const CampaignMetrics = ({ campaign }) => {
  const ctr = campaign?.metrics?.clicks / campaign?.metrics?.impressions ?? 0;
  const budget = campaign?.settings?.budget ?? 'Not set';
  
  return (
    <div>
      <p>CTR: {(ctr * 100).toFixed(2)}%</p>
      <p>Budget: {budget}</p>
      <p>Status: {campaign?.status ?? 'Unknown'}</p>
    </div>
  );
};

// API response handling
const processApiResponse = (response) => {
  const campaigns = response?.data?.campaigns ?? [];
  const totalCount = response?.pagination?.total ?? 0;
  const hasNext = response?.pagination?.hasNext ?? false;
  
  return { campaigns, totalCount, hasNext };
};

BigInt & Dynamic Imports

Large numbers and code splitting
// BigInt for large numbers (useful for impression counts)
const largeImpressions = BigInt("9007199254740992000");
const totalImpressions = largeImpressions + 1000n;

// Dynamic imports for code splitting
const loadChartingLibrary = async () => {
  const { Chart } = await import('./chart-library');
  return Chart;
};

// Practical usage in React
const CampaignChart = ({ data }) => {
  const [ChartComponent, setChartComponent] = useState(null);
  
  useEffect(() => {
    const loadChart = async () => {
      const Chart = await loadChartingLibrary();
      setChartComponent(() => Chart); // Note: () => Chart to store component
    };
    
    loadChart();
  }, []);
  
  if (!ChartComponent) {
    return <div>Loading chart...</div>;
  }
  
  return <ChartComponent data={data} />;
};

ES2021: Logical Assignment Operators

Logical assignment operators made conditional assignments more concise:

Logical assignment operators
// Traditional way
if (!user.preferences) {
  user.preferences = {};
}
if (!user.preferences.theme) {
  user.preferences.theme = 'light';
}

// ES2021 Logical Assignment
user.preferences ??= {};
user.preferences.theme ??= 'light';

// Practical usage in React state
const useCampaignSettings = (initialSettings) => {
  const [settings, setSettings] = useState(initialSettings);
  
  const updateSettings = (newSettings) => {
    setSettings(prev => {
      const updated = { ...prev };
      
      // Only update if values don't exist
      updated.budget ??= newSettings.budget;
      updated.audience ??= newSettings.audience;
      updated.schedule ??= newSettings.schedule;
      
      // Update if truthy
      updated.name ||= newSettings.name;
      
      return updated;
    });
  };
  
  return [settings, updateSettings];
};

// String.replaceAll() - finally!
const cleanCampaignName = (name) => {
  return name
    .replaceAll('_', ' ')
    .replaceAll('-', ' ')
    .trim();
};

ES2022: Private Fields & Top-level Await

Private Class Fields

Private class fields
class CampaignManager {
  // Private fields
  #apiKey;
  #cache = new Map();
  
  // Public field
  maxRetries = 3;
  
  constructor(apiKey) {
    this.#apiKey = apiKey;
  }
  
  // Private method
  #makeRequest = async (url) => {
    // Implementation hidden from outside
    return fetch(url, {
      headers: { 'Authorization': `Bearer ${this.#apiKey}` }
    });
  }
  
  // Public method
  async getCampaign(id) {
    if (this.#cache.has(id)) {
      return this.#cache.get(id);
    }
    
    const response = await this.#makeRequest(`/campaigns/${id}`);
    const campaign = await response.json();
    this.#cache.set(id, campaign);
    
    return campaign;
  }
}

// Usage
const manager = new CampaignManager('secret-key');
const campaign = await manager.getCampaign('123');

// This would throw an error:
// console.log(manager.#apiKey); // SyntaxError

Top-level Await

Top-level await
// config.js - Load configuration at module level
const response = await fetch('/api/config');
const config = await response.json();

export { config };

// analytics.js - Initialize analytics
const analytics = await import('./analytics-library');
await analytics.initialize(config.analyticsKey);

export { analytics };

// main.js - Clean module initialization
import { config } from './config.js';
import { analytics } from './analytics.js';

// Everything is ready to use without complex initialization logic
console.log('App initialized with config:', config.appName);

ES2023: Array Methods & More

ES2023 introduced some useful array methods and other improvements:

New immutable array methods
// Array.findLast() and findLastIndex()
const campaigns = [
  { id: 1, name: 'Spring Sale', status: 'completed' },
  { id: 2, name: 'Summer Sale', status: 'active' },
  { id: 3, name: 'Fall Sale', status: 'active' },
  { id: 4, name: 'Winter Sale', status: 'draft' }
];

// Find the last active campaign
const lastActiveCampaign = campaigns.findLast(c => c.status === 'active');
// { id: 3, name: 'Fall Sale', status: 'active' }

// Array.toReversed(), toSorted(), toSpliced() - immutable versions
const numbers = [3, 1, 4, 1, 5];

// Original array unchanged
const reversed = numbers.toReversed(); // [5, 1, 4, 1, 3]
const sorted = numbers.toSorted(); // [1, 1, 3, 4, 5]
const spliced = numbers.toSpliced(2, 1, 99); // [3, 1, 99, 1, 5]

console.log(numbers); // [3, 1, 4, 1, 5] - unchanged!

// Practical usage in React
const useSortedCampaigns = (campaigns, sortBy) => {
  return useMemo(() => {
    if (!sortBy) return campaigns;
    
    return campaigns.toSorted((a, b) => {
      if (sortBy === 'name') return a.name.localeCompare(b.name);
      if (sortBy === 'date') return new Date(b.created) - new Date(a.created);
      return 0;
    });
  }, [campaigns, sortBy]);
};

// Array.with() - immutable element replacement
const updateCampaignStatus = (campaigns, index, newStatus) => {
  return campaigns.with(index, {
    ...campaigns[index],
    status: newStatus
  });
};

ES2024: The Latest Features

ES2024 brings some exciting new features that are starting to gain browser support:

Object.groupBy() & Map.groupBy()

Grouping objects
// Group campaigns by status
const campaigns = [
  { id: 1, name: 'Spring Sale', status: 'active', type: 'search' },
  { id: 2, name: 'Summer Sale', status: 'paused', type: 'display' },
  { id: 3, name: 'Fall Sale', status: 'active', type: 'search' },
  { id: 4, name: 'Winter Sale', status: 'draft', type: 'video' }
];

// ES2024 groupBy
const groupedByStatus = Object.groupBy(campaigns, campaign => campaign.status);
// {
//   active: [{ id: 1, ... }, { id: 3, ... }],
//   paused: [{ id: 2, ... }],
//   draft: [{ id: 4, ... }]
// }

// More complex grouping
const groupedByTypeAndStatus = Object.groupBy(campaigns, 
  campaign => `${campaign.type}-${campaign.status}`
);

// Practical React usage
const CampaignDashboard = ({ campaigns }) => {
  const groupedCampaigns = useMemo(() => 
    Object.groupBy(campaigns, c => c.status), 
    [campaigns]
  );
  
  return (
    <div className="dashboard">
      {Object.entries(groupedCampaigns).map(([status, campaignList]) => (
        <div key={status} className="campaign-group">
          <h3>{status.toUpperCase()} ({campaignList.length})</h3>
          {campaignList.map(campaign => (
            <CampaignCard key={campaign.id} campaign={campaign} />
          ))}
        </div>
      ))}
    </div>
  );
};

Promise.withResolvers()

Externally resolvable promises
// Before ES2024 - creating externally resolvable promises
function createExternalPromise() {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

// ES2024 - much cleaner
const { promise, resolve, reject } = Promise.withResolvers();

// Practical usage for complex async flows
class CampaignUploader {
  #uploadPromises = new Map();
  
  startUpload(campaignId, data) {
    const { promise, resolve, reject } = Promise.withResolvers();
    this.#uploadPromises.set(campaignId, { resolve, reject });
    
    // Start upload process
    this.#processUpload(campaignId, data);
    
    return promise;
  }
  
  #processUpload = async (campaignId, data) => {
    try {
      const result = await this.#uploadToServer(data);
      const { resolve } = this.#uploadPromises.get(campaignId);
      resolve(result);
    } catch (error) {
      const { reject } = this.#uploadPromises.get(campaignId);
      reject(error);
    } finally {
      this.#uploadPromises.delete(campaignId);
    }
  }
}

// Usage
const uploader = new CampaignUploader();
try {
  const result = await uploader.startUpload('123', campaignData);
  console.log('Upload successful:', result);
} catch (error) {
  console.error('Upload failed:', error);
}

Practical Impact on Modern Development

How These Features Changed Our Codebase

At Google, adopting these JavaScript features has had measurable impacts on our development process:

Modern JavaScript Feature Adoption Impact

Real-World Example: Campaign Management Refactor

Here's how we refactored a critical campaign management function using modern JavaScript:

Legacy vs Modern JavaScript comparison
// Legacy code (pre-ES6)
function processCampaignData(data, options) {
  var campaigns = data && data.campaigns ? data.campaigns : [];
  var settings = options && options.settings ? options.settings : {};
  var filters = options && options.filters ? options.filters : {};
  
  var results = [];
  
  for (var i = 0; i < campaigns.length; i++) {
    var campaign = campaigns[i];
    
    if (filters.status && campaign.status !== filters.status) {
      continue;
    }
    
    var processedCampaign = {
      id: campaign.id,
      name: campaign.name,
      budget: campaign.budget || 0,
      metrics: {}
    };
    
    if (campaign.metrics) {
      if (campaign.metrics.impressions) {
        processedCampaign.metrics.impressions = campaign.metrics.impressions;
      }
      if (campaign.metrics.clicks) {
        processedCampaign.metrics.clicks = campaign.metrics.clicks;
        processedCampaign.metrics.ctr = campaign.metrics.clicks / campaign.metrics.impressions;
      }
    }
    
    results.push(processedCampaign);
  }
  
  if (settings.sortBy) {
    results.sort(function(a, b) {
      if (settings.sortBy === 'name') {
        return a.name.localeCompare(b.name);
      }
      if (settings.sortBy === 'budget') {
        return b.budget - a.budget;
      }
      return 0;
    });
  }
  
  return results;
}

// Modern JavaScript (ES2024)
const processCampaignData = (data, options = {}) => {
  const campaigns = data?.campaigns ?? [];
  const { settings = {}, filters = {} } = options;
  
  return campaigns
    .filter(campaign => !filters.status || campaign.status === filters.status)
    .map(campaign => ({
      id: campaign.id,
      name: campaign.name,
      budget: campaign.budget ?? 0,
      metrics: {
        impressions: campaign.metrics?.impressions ?? 0,
        clicks: campaign.metrics?.clicks ?? 0,
        ctr: campaign.metrics?.clicks && campaign.metrics?.impressions 
          ? campaign.metrics.clicks / campaign.metrics.impressions 
          : 0
      }
    }))
    .toSorted((a, b) => {
      if (!settings.sortBy) return 0;
      if (settings.sortBy === 'name') return a.name.localeCompare(b.name);
      if (settings.sortBy === 'budget') return b.budget - a.budget;
      return 0;
    });
};

// Usage with modern patterns
const CampaignProcessor = () => {
  const [campaigns, setCampaigns] = useState([]);
  const [filters, setFilters] = useState({});
  const [settings, setSettings] = useState({ sortBy: 'name' });
  
  const processedCampaigns = useMemo(() => 
    processCampaignData({ campaigns }, { filters, settings }),
    [campaigns, filters, settings]
  );
  
  return (
    <div>
      {processedCampaigns.map(campaign => (
        <CampaignCard key={campaign.id} campaign={campaign} />
      ))}
    </div>
  );
};

Future Outlook: What's Coming Next

JavaScript continues to evolve at a steady pace. Here are some features in the pipeline that I'm excited about:

Pattern Matching (Proposed)

Proposed pattern matching syntax
// Proposed pattern matching syntax
const processUserAction = (action) => {
  return match (action) {
    when ({ type: 'CREATE_CAMPAIGN', payload: { name, budget } }) -> {
      return createCampaign(name, budget);
    }
    when ({ type: 'UPDATE_CAMPAIGN', payload: { id, ...updates } }) -> {
      return updateCampaign(id, updates);
    }
    when ({ type: 'DELETE_CAMPAIGN', payload: { id } }) -> {
      return deleteCampaign(id);
    }
    when (_) -> {
      throw new Error('Unknown action type');
    }
  };
};

Records & Tuples (Proposed)

Immutable data structures
// Immutable data structures
const campaign = #{
  id: '123',
  name: 'Summer Sale',
  metrics: #{
    impressions: 100000,
    clicks: 5000
  }
};

// This would create a new record
const updatedCampaign = campaign.with({
  metrics: campaign.metrics.with({ clicks: 5100 })
});

// Records are compared by value, not reference
console.log(#{a: 1} === #{a: 1}); // true!

Conclusion: Embracing Modern JavaScript

The evolution from ES6 to ES2024 represents one of the most significant transformations in JavaScript's history. Each yearly release has brought features that solve real developer pain points and enable more expressive, maintainable code.

Key Takeaways for Modern Development

  • Adopt incrementally: You don't need to use every new feature immediately
  • Focus on readability: Modern JavaScript prioritizes developer understanding
  • Embrace immutability: New array methods and patterns reduce bugs
  • Leverage tooling: TypeScript and modern bundlers make adoption safer
  • Consider your team: Balance modern features with team knowledge

At Google, we've seen how adopting these features systematically has improved our code quality, reduced bugs, and made our applications more performant. The key is thoughtful adoption—using new features where they genuinely improve the code, not just because they're new.

JavaScript's future looks bright. The language continues to evolve in response to real-world developer needs, making it more powerful while maintaining its accessibility. Whether you're building small websites or large-scale applications like we do at Google, these modern JavaScript features provide the tools to write better code.

Continue reading

More from the journal.

All writing